C言語由来の文字配列(char[])は低レベルで速そうに見えますが、境界チェックやNUL終端、動的確保など多くの落とし穴があります。
現代C++ではstd::stringとstd::string_viewを使うことで、安全に、そして簡潔に文字列を扱えます。
この記事では、char[]の問題点とstd::stringの実践的な使い方、C APIとの相互運用、移行の勘所までを比較とサンプルコードで詳しく解説します。
C++の文字列はchar[]からstd::stringへ
C++20/23時代の前提とキーワード(C++/文字配列/char[]/std::string/安全/簡単)
C++20/23では、標準ライブラリと言語機能が成熟し、文字列操作の中心はstd::string
とstd::string_view
に移っています。
std::string
は所有・自動メモリ管理・境界チェック(一部API)・例外安全を提供し、std::string_view
はコピーを避けた読み取り専用の軽量参照を提供します。
キーワードは「安全」「簡単」「相互運用」です。
この記事で扱う範囲(文字列操作・比較・サンプルコード)
本記事は、以下を対象にします。
- char[]が抱える代表的な危険と限界
std::string
/std::string_view
の基本APIとベストプラクティス- 性能・メモリ・可読性の比較と選択基準
- C APIとの安全な受け渡し方法
- 既存コードの段階的な移行例と設計指針
すべて実行可能なC++コード例(出力付き)を掲載します。
C++の文字配列(char[])の問題点:バッファオーバーフローと手作業のメモリ管理
NULL終端・長さ管理・strcpy/strcatの危険性
char配列はNULL文字(’\0’)で終端し、長さを自分で管理します。
境界チェックがなく、strcpy
やstrcat
は簡単にバッファオーバーフローを起こします。
// 例: 典型的な危険(実行しないでください)
// バッファサイズより長い文字列をコピーしてしまうと未定義動作
#include <cstdio>
#include <cstring>
int main() {
char buf[8] = {};
// 危険: "TooLong!" は 8文字で終端なし、NULも含めると溢れる
// strcpy(buf, "TooLong!"); // ← 未定義動作(コメントアウト)
// 代わりに長さを制限するAPIを使っても、NUL終端に注意が必要
std::snprintf(buf, sizeof(buf), "%s", "OK"); // 安全な上書き
std::printf("buf=\"%s\"\n", buf);
}
buf="OK"
strncpy
はNULL終端しない場合があるため扱いが難しく、snprintf
のように出力サイズを管理できる関数のほうが現実的です。
可変長・動的確保(new/delete, malloc/free)の複雑さ
文字列長が実行時に変わる場合、new[]/delete[]
やmalloc/free
で手動管理が必要です。
例外や早期returnが絡むと解放漏れや二重解放の温床になります。
RAIIで管理できるstd::string
はこの問題を根本的に回避します。
マルチバイト・Unicodeとchar[]の限界
char配列は「バイト列」でしかなく、UTF-8などの可変長エンコーディングに対して「文字」の単位で安全に扱えません。
1文字を削除するつもりで1バイトを消すと文字化けや不正なシーケンスになります。
エンコーディングを意識した抽象化やライブラリの支援が不可欠です。
std::stringで安全・簡単に文字列操作:基礎とベストプラクティス
生成・連結・検索・部分文字列の基本API(サンプルコード付き)
std::string
は直感的で安全なAPIを多数備えます。
基本操作を一度に確認します。
#include <iostream>
#include <string>
int main() {
// 生成
std::string s1 = "Hello";
std::string s2(3, '!'); // "!!!"
std::string s3 = std::string(" C++");
// 連結(+ と appendの両方が使える)
std::string msg = s1 + s3; // "Hello C++"
msg.append(" ").append(s2); // "Hello C++ !!!"
// 検索
auto pos = msg.find("C++");
bool found = (pos != std::string::npos);
// 部分文字列
std::string head = msg.substr(0, 5); // "Hello"
// 置換
msg.replace(pos, 3, "World"); // "Hello World !!!"
// 先頭/末尾判定(C++20)
bool starts = msg.starts_with("Hello");
bool ends = msg.ends_with("!!!");
std::cout << "msg=" << msg << "\n"
<< "found=" << found << ", pos=" << pos << "\n"
<< "head=" << head << "\n"
<< "starts=" << starts << ", ends=" << ends << "\n";
}
msg=Hello World !!!
found=1, pos=6
head=Hello
starts=1, ends=1
例外安全・自動メモリ管理・境界チェックの利点
std::string
は動的メモリを自動管理し、例外が投げられてもリソースが漏れません。
at()
は境界チェックを行い、範囲外アクセス時に例外を投げます。
operator[]
は未定義動作を避けるため使用箇所を限定し、必要に応じてat()
と使い分けるのが安全です。
#include <iostream>
#include <string>
int main() {
std::string s = "abc";
try {
char c = s.at(10); // 例外: std::out_of_range
std::cout << c << "\n";
} catch (const std::out_of_range& e) {
std::cout << "out_of_range: " << e.what() << "\n";
}
}
out_of_range: basic_string::at: __n (which is 10) >= this->size() (which is 3)
std::string_viewで軽量参照・コピー回避
std::string_view
は非所有のビューで、コピーコストをほぼゼロにできます。
ただし所有しないため、参照先の寿命に注意が必要です。
#include <iostream>
#include <string>
#include <string_view>
void print_view(std::string_view sv) {
std::cout << "len=" << sv.size() << ", data=\"" << sv << "\"\n";
}
std::string_view make_view() {
std::string local = "temporary";
return std::string_view(local); // 危険: ダングリング参照を返す
}
int main() {
std::string s = "hello";
print_view(s); // OK: sの寿命中
print_view("literal"); // OK: リテラルの静的寿命
// 安全な戻り値の設計例
auto safe_factory = []() {
static std::string persist = "persist";
return std::string_view(persist); // 静的ストレージなのでOK
};
print_view(safe_factory());
// ダングリングの例(コメントアウトしておく)
// auto bad = make_view(); // 危険
}
len=5, data="hello"
len=8, data="literal"
len=7, data="persist"
ポイントは「string_viewは入力引数に最適、戻り値には原則として使わない(寿命が明確な場合を除く)」です。
Unicode対応の注意点(UTF-8, std::u8string)
C++20でchar8_t
とstd::u8string
が導入され、UTF-8の型安全性が向上しました。
ただし標準のstd::string
もUTF-8のバイト列格納に広く使われています。
どちらを選ぶかはプロジェクト方針に合わせます。
#include <iostream>
#include <string>
#include <string_view>
int main() {
// UTF-8をstd::stringに格納する一般的な例
std::string s = u8"こんにちは"; // 文字数≠バイト数に注意
std::cout << "bytes=" << s.size() << ", data=" << s << "\n";
// 型安全を高めたい場合のu8string
std::u8string u8 = u8"世界";
// 出力のためにchar8_t* -> const char*へ明示的にキャスト
std::cout << "u8 bytes=" << u8.size()
<< ", data=" << reinterpret_cast<const char*>(u8.c_str()) << "\n";
// 先頭1バイトの削除は文字破壊の可能性(デモ用、実用では避ける)
std::string broken = s;
broken.erase(0, 1);
std::cout << "broken=" << broken << "\n";
}
bytes=15, data=こんにちは
u8 bytes=6, data=世界
broken=��んにちは
文字単位の処理(大文字小文字変換、正規化など)はICUやBoost.Textなどの専用ライブラリを検討してください。
char[]とstd::stringの比較:性能・メモリ・可読性・保守性
std::string
はしばしば「遅い」と誤解されますが、多くの実用ケースで可読性・安全性・開発速度のメリットが圧倒します。
性能面でも小文字列最適化(SSO)やムーブセマンティクスにより、余計な動的確保を避けられる実装が一般的です。
観点 | char[] | std::string |
---|---|---|
所有/寿命 | 手動管理 | 自動管理(RAII) |
境界チェック | なし | 一部APIで提供(at, range-based) |
連結/挿入 | 手作業/危険 | 安全なAPIを提供 |
動的サイズ | 難しい | 容易(append, resizeなど) |
例外安全 | 難しい | 容易 |
可読性/保守性 | 低い | 高い |
相互運用 | C APIと直接互換 | c_str()/data()で橋渡し |
性能 | 上限は高いが危険 | 多くのケースで十分高速 |
いつchar[]を使うべきか(組込み・固定長バッファ・C互換)
- メモリが極端に限られた組込み環境で、確定サイズの固定長バッファしか許容されない場合。
- C APIで「呼び出し側が用意したバッファに書き込む」設計が不可避な場合。
- リアルタイム制約が強く、動的確保を禁止する規約がある場合。
いつstd::stringを使うべきか(一般的なアプリケーション開発)
- 通常のアプリケーション・業務システム・ツール開発全般。
- 可変長の入力がある場面、テキスト処理、ログ組み立て。
- 例外安全・保守性・テスト容易性を重視するコードベース。
コピー・ムーブ・小文字列最適化(SSO)の影響
多くの実装は短い文字列を内部の小バッファに保持(SSO)し、ヒープ確保を回避します。
ムーブはポインタの受け渡しだけで完了するため非常に安価です。
#include <iostream>
#include <string>
#include <utility>
int main() {
std::string a = "short"; // 典型的にはSSOでヒープ確保なし
std::string b = "a somewhat longer string that may allocate";
std::string c = std::move(b); // ムーブ(bの中身は移される)
std::cout << "a=" << a << "\n";
std::cout << "c=" << c << "\n";
std::cout << "b.size()=" << b.size() << " (moved-from)\n";
}
a=short
c=a somewhat longer string that may allocate
b.size()=0 (moved-from)
ムーブ後のオブジェクトは「有効だが未規定の値」を持つことに注意し、再利用前に明示的に代入するのが良い設計です。
C APIとの相互運用:std::stringからchar*への安全な受け渡し
c_str()/data()の使い方と寿命の注意
c_str()
とdata()
は内部バッファへのポインタを返します。
次の変更で無効化される可能性があるため、ポインタを長期保存しないこと、参照する間は元のstd::string
を変更・破棄しないことが重要です。
C++17以降はdata()
がNULL終端を前提に使え、C++17からは非constのdata()
で書き込みも可能になりましたが、サイズを超える書き込みやNULL終端の破壊は未定義動作です。
#include <cstdio>
#include <string>
int main() {
std::string s = "hello";
const char* p = s.c_str(); // 読み取り用
std::printf("%s\n", p);
// 書き込み用途(長さ以内のみ、終端は自分で維持)
s.resize(5); // 5バイト
char* w = s.data(); // C++17以降
w[0] = 'H'; // OK
// w[5] = '!'; // 範囲外: 未定義動作
std::printf("%s\n", s.c_str());
}
hello
Hello
バッファに書き込むC関数の扱い(snprintf, fgets など)
C関数が「書き込み先のバッファとサイズ」を要求する場合、std::vector<char>
または十分にリサイズしたstd::string
を使います。
#include <cstdio>
#include <vector>
#include <string>
#include <iostream>
#include <cstring>
int main() {
// snprintfでバッファに書き込み -> std::stringへ
std::vector<char> buf(64);
int n = std::snprintf(buf.data(), buf.size(), "num=%d, hex=%x", 123, 0x2A);
if (n < 0) return 1; // 失敗
if (static_cast<size_t>(n) >= buf.size()) {
// 収まりきらない場合は再確保して再実行(省略)
}
std::string formatted(buf.data(), static_cast<size_t>(n));
std::cout << formatted << "\n";
// fgetsで読み取り -> std::stringへ
// デモ用にstdinではなく固定文字列を利用(fmemopenはPOSIX)
const char* text = "line1\nline2\n";
FILE* fp = fmemopen((void*)text, std::strlen(text), "r");
std::vector<char> line(16);
if (std::fgets(line.data(), static_cast<int>(line.size()), fp)) {
std::string s(line.data());
std::cout << "read: " << s;
}
std::fclose(fp);
}
num=123, hex=2a
read: line1
書き込み量が不明な関数にはループでサイズを増やす戦略が安全です。
std::getline
で済む場合はC++のAPIを優先しましょう。
安全な代替APIとラッパー設計
C関数を直接公開するのではなく、C++の安全な抽象にラップします。
戻り値にstd::string
、入力にstd::string_view
を受けると、所有・寿命の問題を最小化できます。
// Cのフォーマット関数を安全にラップしてstd::stringを返す
#include <cstdarg>
#include <cstdio>
#include <string>
#include <vector>
std::string vformat(const char* fmt, va_list ap) {
va_list ap2;
va_copy(ap2, ap);
int n = std::vsnprintf(nullptr, 0, fmt, ap2); // 必要サイズ
va_end(ap2);
if (n < 0) return {};
std::string out;
out.resize(static_cast<size_t>(n));
std::vsnprintf(out.data(), out.size() + 1, fmt, ap); // +1は終端分
return out;
}
std::string format(const char* fmt, ...) {
va_list ap;
va_start(ap, fmt);
std::string s = vformat(fmt, ap);
va_end(ap);
return s;
}
#include <iostream>
int main() {
std::string s = format("user=%s, id=%d", "alice", 42);
std::cout << s << "\n";
}
user=alice, id=42
このように「安全なC++ APIを表に出し、C APIは内部実装に留める」が保守性向上の鍵です。
移行ガイドとサンプルコード:char[]からstd::stringへ段階的に置き換える
よくある置き換え例(strcpy→assign, strcat→append, strcmp→compare)
代表的な関数の対応関係をまとめます。
Cの関数/操作 | std::stringでの対応 |
---|---|
strcpy(dst, src) | dst.assign(src) または dst = src |
strcat(dst, src) | dst.append(src) または dst += src |
strcmp(a, b) | a.compare(b) (0, 正/負)または a == b , < など |
strlen(s) | s.size() |
strstr(h, n) | h.find(n) != npos |
memcpy(dst, src, n) | dst.replace(pos, count, src, n) など用途次第 |
実例を一括で移し替えるコードです。
#include <iostream>
#include <string>
int main() {
// Cスタイル
// char buf[64]; std::strcpy(buf, "Hello "); std::strcat(buf, "C");
// if (std::strcmp(buf, "Hello C") == 0) { ... }
// C++スタイル
std::string buf;
buf.assign("Hello ");
buf.append("C++");
bool eq = (buf == "Hello C++");
int cmp = buf.compare("Hello C"); // 正の場合はbufの方が辞書順で大
std::cout << "buf=" << buf << "\n"
<< "eq=" << eq << ", cmp=" << cmp << "\n";
}
buf=Hello C++
eq=1, cmp=2
エラー処理とAPI設計(戻り値にstd::string、引数にstd::string_view)
エラーは例外またはstd::expected
(C++23)/tl::expected
等で表現し、所有権は戻り値で返します。
引数はコピーを避けるためstd::string_view
を受けるのが現代的です。
#include <string>
#include <string_view>
#include <optional>
#include <iostream>
std::optional<std::string> read_name(std::string_view id) {
if (id.empty()) return std::nullopt;
std::string name = "user_" + std::string(id); // 必要時のみ所有化
return name;
}
int main() {
if (auto name = read_name("42")) {
std::cout << *name << "\n";
}
}
user_42
API側でstd::string_view
を使うことで、呼び出し側はstd::string
/文字列リテラル/std::string_view
のいずれでも渡せます。
ベンチマークとコード規約(C++ Core Guidelines/Google Style)
性能評価は最適化を有効にし、ウォームアップや測定ノイズを排除して行います。
Google Benchmarkのようなフレームワークを使うと、SSOの影響やムーブのコストが直感的に比較できます。
ループの外での事前確保や、コンパイラのデッドコード除去対策(DoNotOptimize
)を忘れないことが重要です。
規約面では、C++ Core GuidelinesのR系(リソース管理)とES系(式と文)に従い、「所有はstd::string
、非所有はstd::string_view
」というルールをプロジェクト規約に明文化すると移行が進みます。
Google C++ Style Guideも同様にstring_view
の利用を推奨しており、インターフェースの安定性と性能を両立できます。
まとめ:現代C++ではstd::stringがデフォルト(例外的にchar[]を選ぶ条件)
現代C++では、特段の理由がない限りstd::string
をデフォルトに選ぶべきです。
char[]は固定長・動的確保禁止・厳密なC互換性といった「強い制約」が存在する場合に限り使います。
移行は「公開APIから」段階的に進め、内部実装を安全なC++ APIで包む方針が効果的です。
まとめ
char[]は強力ですが、NULL終端や境界管理、動的確保、Unicodeの扱いなど、開発者に重い負担とリスクを課します。
対してstd::string
はRAII・豊富なAPI・例外安全・SSOとムーブによる高い実効性能を兼ね備え、日常的な文字列処理を安全かつ簡潔にしてくれます。
std::string_view
を併用すればコピーを避けつつ表現力を高められ、C APIともc_str()/data()
や薄いラッパーで堅牢に橋渡しできます。
プロダクションコードでは、公開APIの入力にstd::string_view
、出力にstd::string
を採用し、例外的事情がない限りchar[]は内部最適化やC相互運用の局所に留めるのが、C++20/23時代の実践的ベストプラクティスです。