😺

std::initializer_list を用いた文字列連結

2022/12/17に公開

この記事は C++ Advent Calendar 2022 の17日目の記事です。

std::initializer_list を用いれば

text += { "c++", 20, " hex:", {"{:x}",20}, '\n' };

のような記述で文字列連結が出来そうなのでやってみた、という与太話です。
以下 c++20 で試しています。

1. 文字列のみの連結

まず文字列のみの連結です。
手っ取り早くサンプルを短くするため、std::string の += を用いています。

#include <string>

std::string& operator+=(std::string& l, std::initializer_list<std::string_view> r)
{
    // 全体の文字列長を求める.
    std::size_t n = 0;
    for (auto& it : r)
        n += it.size();
    // 連結前に必要メモリを確保.
    l.reserve(l.size() + n);
    // 連結.
    for (auto& it : r)
        l += it;
    return l;
}

std::initializer_list< std::string_view > で文字列郡を受け取って、連結前に連結後の文字列長を求め、メモリ・アロケート後、文字列連結しています。

文字列の型の違いは std::string_view が char const*, std::string を受け付けるのでお任せ。

使用例
#include <cstdio>

int main() {
    std::string_view str1 = "std::initializer_list";
    std::string      str2 = "文字列連結";
    std::string      text;

    text += { str1, " で", str2, "をするサンプル1\n", };

    std::printf("%s", text.c_str());
    return 0;
}
出力
std::initializer_list で文字列連結をするサンプルその1

連結後文字列が1度のアロケートで済むのが、メリットでしょうか。

std::initializer_list 化のコストは、ローカル変数配列の初期化みたいなモノなので、関数の引数に文字列を列挙してスタックに積んだ場合と大差ないと思います。

ただ、実行時(速度)性能のことを言い出すと、

文字列連結 -- Faith and Brave - C++で遊ぼう

の可変引数を template で受け取って畳み込み式でつなげるのには敵いません。

※ cout でなく printf なのは余計なメモリーアロケート回避のため

2. 数値の文字列化対応

std::string_view の代わりに、文字列だけでなく文字や数値も受け付ける型を用意すれば、使い方が広がります。

#include <string>
#include <type_traits>
#include <charconv>

// 文字列や数値を受け取る型.
struct str_cat_elem {
    // 文字列受け取り.
    str_cat_elem(char const* p) : sv_(p) { }
    str_cat_elem(std::string_view s) : sv_(s) { }
    str_cat_elem(std::string const& s) : sv_(s) { }
    // 文字受け取り.
    str_cat_elem(char c) : sv_(buf_, 1) { buf_[0] = c; buf_[1] = 0; }

    // 数値受け取り.
    template<typename T>
    requires (std::is_arithmetic_v<T>)
    str_cat_elem(T t) {
        auto rc = std::to_chars(buf_, buf_ + sizeof(buf_), t);
        sv_ = std::string_view(buf_, rc.ptr - buf_);
    }

    // 結果の取り出し用.
    operator std::string_view const&() const { return sv_; }

private:
    std::string_view sv_;
    char             buf_[24];
};

std::string& operator+=(std::string& l, std::initializer_list<str_cat_elem> r) {
    std::size_t n = 0;
    for (auto& it : r)
        n += std::string_view(it).size();
    l.reserve(l.size() + n);
    for (auto& it : r)
        l += std::string_view(it);
    return l;
}

文字列化のため charバッファ追加、メモリ消費が数倍になりますが、アロケートするよりは吉としておきます。
なるべく、"文字列のみ" から実行時コストを増やしたくない方針です。

文字列 char const*、string_view、string については string_view 型変数sv_ に入れるだけ(buf_ は放置)。
char文字、数値は buf_ に実体書き込んで sv_設定。

数値の型判定は requires で <type_traits> の std::is_arithmetic_v 任せ。
数値の文字列変換は <charconv> の std::to_chars のデフォルト任せ。
なので浮動小数点数の変換は printf の "%g" (="%.6g") 同様。

バッファサイズは 64bit整数前提で、整数値は最大 20 文字、"%g" は10数文字なので、手抜きで直で 24 にしています。(※int128_t があると破綻)

operator+= は、str_cat_elem 型から operator std::string_view 経由で文字列情報を取り出すため、その記述が増えています。

#include <cstdio>

int main() {
    std::string_view str1 = "std::initializer_list";
    std::string      str2 = "文字列連結";
    int              n    = 0;
    std::string      text;

    text += {
        ++n, ". ", str1, " で ", str2, "をするサンプルその", 2, '\n',
        ++n, ". ", '@', "、", 123.456, " 等、文字や数値も受け付ける.\n",
    };

    std::printf("%s", text.c_str());
    return 0;
}
1. std::initializer_list で 文字列連結をするサンプルその2
2. @、123.456 等、文字や数値も受け付ける.

まとめて連結する分には std::stream の << より見やすく記述できているのでは、と思います。

初期化子リストは並び順に実行されるのが保証されているので、++n のような副作用のある式を気にせず使えるいうのはちょっとメリットかも知れません。

で、次に書式指定が欲しくなったりするのですが。

    // 整数.
    template<std::integral T>
    str_cat_elem(char const* fmt, T t) { …… }

    // 浮動小数点数.
    template<std::floating_point T>
    str_cat_elem(char const* fmt, T t) { …… }

のようなコンストラクタを str_cat_elem に追加実装すれば {"...", val} の形で書式指定できるようになります。
が、実際作り込むと数百行、サンプルとしては大変なので、少し方針転換します。

3. std::format 書式を使う

<format> にある std::vformat_to を用いれば楽に書式指定の文字列化が行なえます。

#include <string>
#include <iterator>
#include <format>

// 文字列や数値を受け取る型.
struct str_cat_elem {
    str_cat_elem(char const* p) : sv_(p) { }
    str_cat_elem(std::string_view s) : sv_(s) { }
    str_cat_elem(std::string const& s) : sv_(s) { }
    str_cat_elem(char c) : str_(1,c) { sv_ = str_; }

    template<typename T>	// 文字列以外の数値等の変換.
    str_cat_elem(T t) : str_cat_elem("{}", t) {}

    template<typename T>	// 書式指定版.
    str_cat_elem(char const* fmt, T t) {
        std::vformat_to(std::back_insert_iterator(str_), fmt, std::make_format_args(t));
        sv_ = str_;
    }

    operator std::string_view const&() const { return sv_; }

private:
    std::string_view sv_;
    std::string      str_;
};

std::string& operator+=(std::string& l, std::initializer_list<str_cat_elem> r) {
    std::size_t n = 0;
    for (auto& it : r)
        n += std::string_view(it).size();
    l.reserve(l.size() + n);
    for (auto& it : r)
        l += std::string_view(it);
    return l;
}

文字列以外はすべて、書式指定変換へ丸投げしています。

書式指定はいろいろな変換が可能で、固定バッファでは間に合わないので、バッファとして std::string を用います。
※ バッファを使わない char const*, string_view string でもバッファの初期化が入りますが、チリのような実行コストとして気しない

std::vformat_to の使い方は

std::format_to -- cpprefjp - C++日本語リファレンス

を見よう見まね、1引数なので単純になりましたが、詳しくはそちらを参照してください。

back_insert_iterator で追記の形なので、予めのアロケートはなく都度、伸長するのでしょう。

#include <cstdio>

int main() {
    char const* str1 = "std::initializer_list";
    std::string str2 = "文字列連結";
    double      dval = 45.67;
    int         n    = 0;
    std::string text;

    text += {
        ++n, ". ", str1, " で ", str2, "をするサンプルその", 3, '\n',
        ++n, ". ", '@', "、", 1234, "、", dval, " 等、文字や数値も受け付ける.\n",
        ++n, ". std::format書式指定で ", {"{:#04x}", 1234},
             " や ", {"{:.8e}", dval}, " のようにも変換可能.\n",
    };

    std::printf("%s", text.c_str());
    return 0;
}
1. std::initializer_list で 文字列連結をするサンプルその3
2. @、1234、45.67 等、文字や数値も受け付ける.
3. std::format書式指定で 0x4d2 や 4.56700000e+01 のようにも変換可能.

{"書式", 値} という書き方は結構気に入っています。
std::format 利用のため 書式文字列内にも {} が入るのは玉にキズですが。

std::string は少量ならアロケートせず内部バッファを用いる実装になっている場合があり、以前調べた

std::string でメモリーアロケートが発生しない最大バイト数

では、64bit環境の VC, g++(libstdc++) で 15文字(バイト)、clang(libc++)で22文字でした。

数値の変換を意識的にこの範囲に留めておけば、アロケート量を減らして文字列連結できるでしょう。
※ このサンプル範囲では += 部分でしかアロケートしていないはず?(未確認)

逆に長くなる書式変換が多いようなら素直に std::format を使うほうがいいでしょう。
てか、普通は、std::format 使っとけ、なんですが。

おわりに

過去との互換性を無視して最近の c++ の言語機能で文字列型を作るとどうだろう、と、妄想してたときに思いついたネタです。

グローバルな std::string の += を野良で定義するという、お仕事じゃ出来ない無作法ですが、お遊びとして大目に見てやってください。
関数で text = str_cat({……});、class で text = str_cat{ ……}; のような表記のも試してみましたが、ハナっから std::format に負けてる気がして止めました。

割と気に入って自作で使おうかとも思ったりしますが、ぶっちゃけ、予めメモリー確保してから書き込むことが規定された std::format 互換関数があればいいのでは、と、思い始めたところで――std::formatをもっと知る必要あるなで――終わりにさせていただきます。

Discussion