Search code examples
c++c++23

Function that executes a block of code once per std::source_location where the invocation takes place


I'm trying to write a function that will be called from lots of different places. Part of this function should only occur once, but "once" per filename + linenumber making the invokation.

#include <iostream>
#include <source_location>

void Bar(const std::source_location& location = std::source_location::current())
{
  // only do this once per unique location
  std::cout << location.file_name()
            << " (" << location.line() << ")"
            << std::endl;

  // but do this repeatedly
  std::cout << "call multiple times" << std::endl;
}

void Foo()
{
  std::cout << "Called" << std::endl;
  Bar();
}

int main()
{
  Bar();
  Foo();
  Foo();
  return 0;
}

https://godbolt.org/z/jhba98cPK (compiles under GCC 13.3 with -std=c++23)

Current output:

example.cpp (23)
call multiple times
Called
example.cpp (18)
call multiple times
Called
example.cpp (18)
call multiple times

What I want:

example.cpp (23)
call multiple times
Called
example.cpp (18)
call multiple times
Called
call multiple times

Preferably it's all rolled into a single function call so that I don't need to litter static variables around the calling site. I tried wrapping this all up into a templated function:

template<typename UniqueT, typename Func>
void call_once(Func& func)
{
    func();
}

but I can't find a way to generate a unique type per calling site. I thought maybe I could generate a unique hash per std::source_location, but I'm not sure how (because the "source_location" is a default parameter, and in any case, I don't know if I can calculate it at compile time). I looked into std::call_once but this requires that I hold a flag, which I guess can be static, but it would prevent the multiple-part of the function from occurring. At least as near as I can figure.

I'd prefer not to hold an std::unordered_map etc somewhere, and would much prefer it occurred compile-time if it was at all possible.


Solution

  • Case 1) Assuming file_name has bounded size (known at compile time).

    The fundamental idea is to pass the source_location as a template parameter of Bar instead of a function argument. Bar could be rewritten as such:

    struct Runner {
        Runner(auto&& callable) { callable(); }
    }
    
    template<Location location> // <-- Location type coming later.
    void Bar() {
        static Runner once{[]() {
            // code to run once.
            std::cout << location.file_name() << '(' << location.line() << ')';
            std::cout << "executed once per location on 1st Bar<location> call" << std::endl;
        }};
    
        // code to run multiple time.
        std::cout << "executed each time" << std::endl;
    }
    

    A few things about this implementation (that might be desirable or not):

    • static Runner once initialization is thread-safe.
    • static initialization is blocking: other Bar<someLoc> calls won't run the "each time" section until one Bar<someLoc> call has successfully run the "once" section.
    • in case the Runner constructor throws, other calls of Bar will attempt to run the "once" section again.
    • A static std::once_flag with a std::call_once should have the same observable behaviour (however the generated assembly seemed less optimized on gcc).

    The template parameter can't directly be of type std::source_location, so we'll have to define a custom Location type to pass as NTTP. First let's make a string class that can be used as NTTP instead of const char* (these constexpr_string/fixed_string/sized_string are a common c++ metaprogramming trick):

    template<std::size_t capacity>
    struct ConstexprString {
    public:
        [[nodiscard]]
        consteval ConstexprString(char const* source)
            : size_{ 0 }
        {
            // basic C str functions not constexpr. Reinventing the wheel...
            while (source[size_] != '\0') {
                if (size_>= capacity) {
                    throw std::invalid_argument("input string does not fit.");
                }
                content_[size_] = source[size_];
                ++size_;
            }
            // constexpr values can't have uninitialized bytes.
            if (capacity > size_) {
                std::ranges::fill_n(content_ + size_, capacity - size_, '\0');
            }
        }
    
        [[nodiscard]]
        constexpr std::string_view view() const {
            return { content_, size_ };
        }
    
        // To use this class as a NTTP, member variable can't be private.
        std::size_t size_;
        char content_[capacity]; // not necessarily '\0' terminated.
    };
    

    Then we can define our NTTP compatible class Location:

    struct Location {
    public:
        static constexpr std::size_t fileCapacity = 128;
        static constexpr std::size_t functionCapacity = 128;
    
        [[nodiscard]]
        consteval Location(std::source_location const& location)
            : column_{ location.column() }
            , line_{ location.line() }
            , fileName_{ location.file_name() }
            , functionName_{ location.function_name() }
        {}
    
        [[nodiscard]]
        constexpr std::string_view file_name() const {
            // personal take: string_view feels more "correct" than 'const char*' in modern c++.
            return fileName_.view();
        }
    
        [[nodiscard]]
        constexpr std::string_view function_name() const {
            return functionName_.view();
        }
    
        [[nodiscard]]
        constexpr std::uint_least32_t column() const {
            return column_;
        }
    
        [[nodiscard]]
        constexpr std::uint_least32_t line() const {
            return line_;
        }
    
        // To use Location as a NTTP, member variable can't be private.
        std::uint_least32_t column_;
        std::uint_least32_t line_;
        ConstexprString<fileCapacity> fileName_;
        ConstexprString<functionCapacity> functionName_;
    };
    

    We're almost done with the framework. Usage:

    // last helper function
    [[nodiscard]]
    consteval std::source_location here(std::source_location const& loc = std::source_location::current()) {
        return loc;
    }
    
    void Foo()
    {
      std::cout << "Called" << std::endl;
      Bar<here()>();
    }
    
    int main()
    {
      Bar<here()>();
      Foo();
      Foo();
      return 0;
    }
    

    Live demo (godbolt).

    All of this should be portable c++20.

    Case 2) Variable string capacity.

    Location can be made to have just the right capacity for its strings. However my solution requires Bar to take 2 template parameters: calling it is more verbose.

    Again, let's make a NTTP compatible class to compute the sizes of the strings:

    // std::strlen isn't constexpr. Reinventing the wheel...
    [[nodiscard]]
    constexpr std::size_t StrLen(char const* str) {
        char const* cur = str;
        while (*cur != '\0') {
            ++cur;
        }
        return cur - str;
    }
    
    struct LocationSize {
    public:
        [[nodiscard]]
        constexpr LocationSize(std::source_location const& location)
            : fileLength{ StrLen(location.file_name()) }
            , functionLength{ StrLen(location.function_name()) }
        {}
    
        std::size_t fileLength;
        std::size_t functionLength;
    };
    

    Templating Location to use variable sized strings:

    template<LocationSize sizes>
    struct Location {
    public:
        static constexpr std::size_t fileCapacity = sizes.fileLength;
        static constexpr std::size_t functionCapacity = sizes.functionLength;
    
        // rest is unchanged
    };
    

    Usage:

    void log(auto const& location, std::string_view message) {
        std::cout << location.file_name() << '(' << location.line() << ';' << location.column() << ')';
        std::cout << " from '" << location.function_name() << "': ";
        std::cout << message << '\n';
    }
    
    template<LocationSize ls, Location<ls> location>
    void Bar()
    {
        static Runner once{[]() {
            // only do this once per unique location
            log(location, "once section");
        }};
    
        // but do this repeatedly
        log(location, "repeat section");
    }
    
    void Foo()
    {
        std::cout << "Foo Call" << std::endl;
        constexpr auto loc = here();
        Bar<loc,loc>();
    }
    
    int main()
    {
        Bar<here(),here()>();
        Foo();
        Foo();
        return 0;
    }
    

    Live demo(godbolt)

    The magic is happening in template<LocationSize ls, Location<ls> location>. Both types are convertible from std::source_location and should be called with the same source_location passed twice. The first conversion to LocationSize ls extracts the string sizes, and is just an intermediate value to compute the exact type of the second parameter.

    Calling Bar<here(),here()>(); in main is a bit hacky, since we're passing 2 different source_location. But it should work anyway since what really matters is that they have the same file_name/function_name strings. And the syntax is significantly more compact than declaring a constexpr variable.

    Warning: binary size

    A different Bar template is being instanciated at each call site, causing the usual assembly code duplication. On top of that, each Bar instanciation causes the template parameter location to be stored as a constant in the program memory, each with its own copy of file_name/function_name. Depending on your build process, file_name might be the complete absolute path of the source file...

    This is NOT zero cost.