C++ で独自 string クラスを実装する手順

はじめに

本稿では、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_ = 5cap_ = 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つずつずらします。 possize_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)へ移行しつつあります。

タグ: C++ String クラス実装 コピーコンストラクタ 代入演算子

8月3日 22:46 投稿