はじめに
本稿では、C++ 標準ライブラリの std::string に似た独自の文字列クラスをゼロから実装する方法を解説します。
標準ライブラリとの混同を避けるため、すべてのコードを MyString という名前空間内に記述します。
メンバ変数
以下の3つのメンバ変数を用います。
char* data_: 文字列本体(終端'\0'を含む動的確保領域)size_t len_: 現在の文字列長('\0'を含まない)size_t cap_: 確保済みバッファ容量(len_と同様、'\0'を除く)
例えば文字列 "hello" の場合、data_ は 6 バイト確保され('h','e','l','l','o','\0')、len_ = 5、cap_ = 5 となります。
初期状態(空文字列)でも cap_ は 0 ではなく、実装によって異なりますが、ここでは必要に応じて拡張します。
コンストラクタ
デフォルトでは空文字列で初期化します。引数に const char* を取ります。
MyString::MyString(const char* s = "")
{
len_ = std::strlen(s);
cap_ = len_;
data_ = new char[cap_ + 1];
std::strcpy(data_, s);
}
初期化リストを使えない理由: data_ が char* で、引数が const char* のため、直接代入すると const 性が失われます。
また、初期化リストの順序はメンバ宣言順に依存し、data_ が cap_ より先に宣言されている場合、data_ を初期化する時点で cap_ の値が不定になる危険があります。
デストラクタ
~MyString()
{
delete[] data_;
}
サイズ取得関数
size_t size() const noexcept { return len_; }
size_t length() const noexcept { return len_; }
添字演算子 []
const 版と非 const 版の両方を提供します。
char& operator[](size_t pos)
{
assert(pos < len_);
return data_[pos];
}
const char& operator[](size_t pos) const
{
assert(pos < len_);
return data_[pos];
}
イテレータ
内部では単にポインタをそのまま使います。
using iterator = char*;
using const_iterator = const char*;
iterator begin() noexcept { return data_; }
iterator end() noexcept { return data_ + len_; }
const_iterator begin() const noexcept { return data_; }
const_iterator end() const noexcept { return data_ + len_; }
これで範囲 for ループが使えるようになります。
コピーコンストラクタと代入演算子(深いコピー)
デフォルトのコピーは浅いコピー(ポインタの共有)となり、二重解放の原因になります。 独自に深いコピーを実装します。
コピーコンストラクタ
MyString(const MyString& rhs)
: data_(new char[rhs.cap_ + 1])
, len_(rhs.len_)
, cap_(rhs.cap_)
{
std::strcpy(data_, rhs.data_);
}
代入演算子
自己代入のチェックが必要です。また、new 失敗時の例外安全にも配慮します。
MyString& operator=(const MyString& rhs)
{
if (this != &rhs)
{
char* tmp = new char[rhs.cap_ + 1];
std::strcpy(tmp, rhs.data_);
delete[] data_;
data_ = tmp;
len_ = rhs.len_;
cap_ = rhs.cap_;
}
return *this;
}
「現代的な」書き方(コピー&スワップ)
標準の std::swap を使う代わりに、自分自身の swap メンバ関数を定義すると効率的です。
void swap(MyString& other) noexcept
{
std::swap(data_, other.data_);
std::swap(len_, other.len_);
std::swap(cap_, other.cap_);
}
// コピーコンストラクタ(コピー&スワップ版)
MyString(const MyString& rhs)
: MyString(rhs.data_) // 委譲コンストラクタ
{
}
// 代入演算子(コピー&スワップ版)
MyString& operator=(MyString rhs) // 値渡しでコピー
{
swap(rhs);
return *this;
}
この方法では、自己代入のチェックが不要になり、例外安全も向上します。
ただし、std::swap をそのまま使うと無限再帰が発生する可能性があるため、必ずメンバ関数の swap を呼び出してください。
reserve と resize
reserve
容量を確保します。縮小は行いません。
void reserve(size_t new_cap)
{
if (new_cap <= cap_) return;
char* new_data = new char[new_cap + 1];
std::strcpy(new_data, data_);
delete[] data_;
data_ = new_data;
cap_ = new_cap;
}
resize
長さを変更し、余分な領域を指定文字で埋めます。
void resize(size_t n, char c = '\0')
{
if (n > cap_)
reserve(n);
if (n > len_)
std::memset(data_ + len_, c, n - len_);
data_[n] = '\0';
len_ = n;
}
文字列追加
push_back
void push_back(char ch)
{
if (len_ + 1 > cap_)
reserve(cap_ == 0 ? 1 : cap_ * 2);
data_[len_] = ch;
data_[len_ + 1] = '\0';
++len_;
}
append
MyString& append(const char* s)
{
size_t add_len = std::strlen(s);
if (len_ + add_len > cap_)
reserve(len_ + add_len);
std::strcpy(data_ + len_, s);
len_ += add_len;
return *this;
}
operator+=
MyString& operator+=(char ch)
{
push_back(ch);
return *this;
}
MyString& operator+=(const char* s)
{
return append(s);
}
insert
文字の挿入
指定位置 pos に文字 ch を挿入します。後ろの文字を1つずつずらします。
pos が size_t 型であることに注意し、ループ条件の無限ループを避けるため、end 変数を len_+1 から始めて data_[end] = data_[end-1] で後方シフトします。
MyString& insert(size_t pos, char ch)
{
assert(pos <= len_);
if (len_ + 1 > cap_)
reserve(cap_ == 0 ? 1 : cap_ * 2);
// 後方シフト('\\0' を含む)
for (size_t i = len_ + 1; i > pos; --i)
data_[i] = data_[i - 1];
data_[pos] = ch;
++len_;
return *this;
}
文字列の挿入
MyString& insert(size_t pos, const char* s)
{
size_t slen = std::strlen(s);
assert(pos <= len_);
if (len_ + slen > cap_)
reserve(len_ + slen);
// 後方シフト
for (size_t i = len_ + slen; i > pos + slen - 1; --i)
data_[i] = data_[i - slen];
for (size_t i = 0; i < slen; ++i)
data_[pos + i] = s[i];
len_ += slen;
data_[len_] = '\0';
return *this;
}
erase
指定位置から指定文字数を削除します。第2引数のデフォルト値は npos とし、npos は -1(最大値)として定義します。
static const size_t npos = -1;
MyString& erase(size_t pos = 0, size_t count = npos)
{
assert(pos <= len_);
if (count > len_ - pos)
count = len_ - pos;
size_t tail = len_ - (pos + count);
if (tail != 0)
std::memmove(data_ + pos, data_ + pos + count, tail);
len_ -= count;
data_[len_] = '\0';
return *this;
}
ストリーム入出力
フレンド関数として定義します。
出力 operator<<
std::ostream& operator<<(std::ostream& os, const MyString& s)
{
for (size_t i = 0; i < s.len_; ++i)
os << s.data_[i];
return os;
}
入力 operator>>
空白を区切りとするため、std::istream::get() を用います。
std::istream& operator>>(std::istream& is, MyString& s)
{
s.clear(); // 既存内容を破棄
s.reserve(256); // 事前確保で効率化
char ch;
while (is.get(ch) && !std::isspace(ch))
s.push_back(ch);
return is;
}
clear 関数は len_ = 0; data_[0] = '\0'; と実装します。
find
文字検索
size_t find(char ch, size_t pos = 0) const
{
for (size_t i = pos; i < len_; ++i)
if (data_[i] == ch) return i;
return npos;
}
部分文字列検索
size_t find(const char* sub, size_t pos = 0) const
{
const char* p = std::strstr(data_ + pos, sub);
if (!p) return npos;
return static_cast<size_t>(p - data_);
}
substr
MyString substr(size_t pos = 0, size_t count = npos) const
{
if (count > len_ - pos)
count = len_ - pos;
char* tmp = new char[count + 1];
std::strncpy(tmp, data_ + pos, count);
tmp[count] = '\0';
MyString result(tmp);
delete[] tmp;
return result;
}
実装の違い(VS / GCC)
本実装は基本的なものです。実際の標準ライブラリでは以下のような最適化が行われています。
- Visual Studio での実装: 短い文字列(16文字未満)では動的確保を行わず、内部の固定バッファ(
_Bx共用体)を使います。これによりsizeofが大きくなりますが、小さな文字列のパフォーマンスが向上します。 - GCC での実装: 参照カウントとコピーオンライト(COW)を採用しています。複数の文字列オブジェクトが同じ内部バッファを共有し、書き込みが発生した時点で実際のコピーが行われます。ただし、C++11 以降の標準では
std::stringに COW は禁止されているため、最近の GCC では COW から SSO(Small String Optimization)へ移行しつつあります。