Search code examples
c++c++20string-literalscompile-timestring-view

Creating a compile time string repeating a char n times


I'm using a function like this to export data in a xml file (note: silly example):

void write_xml_file(const std::string& path)
{
    using namespace std::string_view_literals; // Use "..."sv

    FileWrite f(path);
    f<< "<root>\n"sv
     << "\t<nested1>\n"sv
     << "\t\t<nested2>\n"sv
     << "\t\t\t<nested3>\n"sv
     << "\t\t\t\t<nested4>\n"sv;
     //...
}

Where those << take a std::string_view argument:

FileWrite& FileWrite::operator<<(const std::string_view s) const noexcept
   {
    fwrite(s.data(), sizeof(char), s.length(), /* FILE* */ f);
    return *this;
   }

If necessary I can add an overload with std::string, std::array, ...

Now, I'd really love to write the above like this:

// Create a compile-time "\t\t\t..."sv
consteval std::string_view indent(const std::size_t n) { /* meh? */ }

void write_xml_file(const std::string& path)
{
    using namespace std::string_view_literals; // Use "..."sv

    FileWrite f(path);
    f<< "<root>\n"sv
     << indent(1) << "<nested1>\n"sv
     << indent(2) << "<nested2>\n"sv
     << indent(3) << "<nested3>\n"sv
     << indent(4) << "<nested4>\n"sv;
     //...
}

Is there someone that can give me an hint on how implement indent()? I'm not sure if my idea to return a std::string_view pointing to a static constant buffer allocated at compile time is the most appropriate, I'm open to other suggestions.


Solution

  • If you want indent to work at compile-time, then you will require N to also be a compile time value, or for indent to be called as part of a constexpr sub-expression.

    Since this is for the purpose of streaming to some file-backed stream object FileWrite, the latter is out -- which means that you need N to be at compile-time (e.g. pass it as a template argument).

    This will change your signature to:

    template <std::size_t N>
    consteval auto indent() -> std::string_view
    

    The second part of the problem is that you want this to return a std::string_view. The complication here is that constexpr contexts don't allow static variables -- and thus anything you create within the context will have automatic storage duration. Technically speaking, you can't just simply create an array in the function and return a string_view of it -- since this would lead to a dangling pointer (and thus UB) due to the storage going out-of-scope at the end of the function. So you will need to workaround this.

    The easiest way is to use a template of a struct that holds a static array (in this case std::array so we can return it from a function):

    template<std::size_t N>
    struct indent_string_holder
    {
        // +1 for a null-terminator. 
        // The '+1' can be removed since it's not _technically_ needed since 
        // it's a string_view -- but this can be useful for C interop.
        static constexpr std::array<char,N+1> value = make_indent_string<N>();
    };
    

    This make_indent_string<N>() is now just a simple wrapper that creates a std::array and fills it with tabs:

    // Thanks to @Barry's suggestion to use 'fill' rather than
    // index_sequence
    template <std::size_t N>
    consteval auto make_indent_string() -> std::array<char,N+1>
    {
        auto result = std::array<char,N+1>{};
        result.fill('\t');
        result.back() = '\0';
        return result;
    }
    

    And then indent<N> just becomes a wrapper around the holder:

    template <std::size_t N>
    consteval auto indent() -> std::string_view
    { 
        const auto& str = indent_string_holder<N>::value;
    
        // -1 on the size if we added the null-terminator.
        // This could also be just string_view{str.data()} with the
        // terminator
        return std::string_view{str.data(), str.size() - 1u}; 
    }
    

    We can do a simple test to see if this works at compile-time, which it should:

    static_assert(indent<5>() == "\t\t\t\t\t");
    

    Live Example

    If you check the assembly, you will also see that indent<5>() produces the correct compile-time string as desired:

    indent_string_holder<5ul>::value:
            .asciz  "\t\t\t\t\t"
    

    Although this works, it would actually probably be much simpler for you to write indent<N>() in terms of FileWrite (or whatever the base class is -- assuming this is ostream) instead of returning a string_view. Unless you are doing buffered writing to these streams, the cost of writing a few single characters should be minimal compared to the cost of flushing the data -- which should make this negligible.

    If this is acceptible, then it would actually be much easier since you can now write it as a recursive function that passes \t to your stream object, and then calls indent<N-1>(...), e.g.:

    template <std::size_t N>
    auto indent(FileWrite& f) -> FileWrite&
    {
        if constexpr (N > 0) {
            f << '\t'; // Output a single tab
            return indent<N-1>(f);
        }
        return f;
    }
    

    This changes the use to now be like:

    FileWrite f(path);
    
    f<< "<root>\n"sv;
    indent<1>(f) << "<nested1>\n"sv;
    indent<2>(f) << "<nested2>\n"sv;
    indent<3>(f) << "<nested3>\n"sv;
    indent<4>(f) << "<nested4>\n"sv;
    

    But the implementation is much easier to grok and understand IMO, compared to producing a string at compile-time.

    Realistically, at this point it might just be cleaner to write:

    auto indent(FileWrite& f, std::size_t n) -> FileWrite&
    {
        for (auto i = 0u; i < n; ++i) { f << '\t'; }
        return f;
    }
    

    which is likely what most people would expect to read; although it does come at the minimal cost of the loop (provided the optimizer does not unroll this).