ことの経緯
大人の事情なのか、std::stringには自身を指定した文字で分割する機能がない。
Pythonのstrクラスにはあるのに(splitメソッド)。
C++にも欲しい。
そんな感じ。
対象とする読者
std::stringクラスに文字列を分割する機能が欲しい人 ← 多分結構参考になる
std::stringクラスに機能を追加したい人 ← 多分まぁまぁ参考になる
C++で既存のクラスを拡張したい人 ← 多分そこそこ参考になる
C++以外を所望の人 ← 多分あまり参考にならない
オブジェクト指向開発とC++の基本的な知識があれば、さほど難しい内容ではないと思う。
(というかこの記事の内容自体C++の基本の範疇と言えると思う)
実装
長いので分割して解説する。
全体のコードを見たい方は以下を参照されたし。
string_ex.cpp
string_ex.h
クラス定義
クラス定義はこんな感じ。
std::stringクラスを継承したクラスを定義し、
実装したいSplitメソッドを定義している。
注意すべき点としてはstd::stringクラスを継承しても、
コンストラクタと代入演算子(=)は継承されないため、
派生クラスの方で実装する必要がある。
デフォルトコンストラクタとconst char *にも対応させたいので、
3つのコンストラクタを定義した。
#pragma once
#include <string>
#include <vector>
class StringEx : public std::string {
public:
StringEx();
StringEx(const char *str);
StringEx(const std::string str);
std::vector<std::string> Split(char sep=' ');
StringEx& operator=(const char *str);
StringEx& operator=(const std::string& str);
};
コンストラクタ
コンストラクタの実装は基底クラスの初期化を行うのみである。
これはそんな難しいことはないと思う。
#include <string>
#include <vector>
#include <sstream>
#include "string_ex.h"
StringEx::StringEx() : std::string() {}
StringEx::StringEx(const char *str) : std::string(str) {}
StringEx::StringEx(const std::string str) : std::string(str) {}
代入演算子
代入演算子も難しいことはなく、基底クラスの代入演算子を呼び出すだけである。
代入演算子に限らず、演算子のオーバーロードは種類に応じて引数(IN)と戻り値(OUT)のインターフェースが決まっているので、
それに則ることだけ注意すればそれほど難しくないと思う。
StringEx& StringEx::operator=(const char *str) {
std::string::operator=(str);
return *this;
}
StringEx& StringEx::operator=(const std::string& str) {
std::string::operator=(str);
return *this;
}
メインディッシュ
Pythonのstr.splitメソッドは引数の型が文字列、戻り値の方が文字列のリストである。
本ページのSplitメソッドも原則これに則ろうと思うが、
引数に複数の文字を含められると処理が面倒なので、
引数の型はシンプルに文字(char)とする。
戻り値の型はstd::vector<std::string>が妥当だろう。
Splitメソッドの実現方式はいくつか考えられるが、参考資料が多かったstd::stringstreamを利用する方法を採用した。
std::stringstreamの詳細な解説は他に譲るが、それ以外は難しいことはないと思う。
std::vector<std::string> StringEx::Split(char sep) {
std::vector<std::string> strs;
std::stringstream ss(*this);
std::string buf;
while(std::getline(ss, buf, sep)) {
strs.push_back(buf);
}
return strs;
}
単体試験
string_ex.hをインクルードしてStringExクラスを利用しようとする人は別途main関数を定義したいと思う。
そういう人にとってはstring_ex.cppにmain関数を定義されてると邪魔極まりないと思う。
なので、string_ex.cppにおけるmain関数は単体試験専用としてプリプロセッサ定義する。
単体試験は引数なしと引数ありの2通りだけ実装した。
std::stringクラスを継承しているので、ストリーム演算子(<<)をそのまま使用することもできる。
#ifdef UNIT_TEST_STRING_EX
#include <iostream>
int main(int argc, char **argv) {
StringEx str1 = std::string("test0 test1 test2");
std::cout << "str1=" + str1 << std::endl;
std::vector<std::string> str1s = str1.Split();
std::cout << "str1s.size()=" << str1s.size() << std::endl;
for (auto s : str1s) std::cout << s << std::endl;
StringEx str2 = std::string("test0,test1,test2,test3");
std::cout << "str2=" + str2 << std::endl;
std::vector<std::string> str2s = str2.Split(',');
std::cout << "str2s.size()=" << str2s.size() << std::endl;
for (auto s : str2s) std::cout << s << std::endl;
return 0;
}
#endif // UNIT_TEST_STRING_EX
実行結果は以下になると思う。
str1=test0 test1 test2
str1s.size()=3
test0
test1
test2
str2=test0,test1,test2,test3
str2s.size()=4
test0
test1
test2
test3
2021/02/17 追記1
コメントで有識者から指摘を頂いた。
上記の実装ではおそらくほとんどすべての処理系で無害と思いますが、デストラクタが仮想ではない std::string を基底クラスにするのはおすすめできません。
それと。
std::string の public な操作のみで split が実装できますので、非メンバな関数にするのがおすすめです。
なるほど。
確かに調べてもstd::stringを基底クラスにしていた資料は少なかった。
そもそも、publicな操作のみで完結する=クラス内部で実装する必要はない、ということか、
それに加えてデストラクタに関する問題もあるので、非推奨なことをしているわけだ。
でだ、
上記の実装ではおそらくほとんどすべての処理系で無害と思いますが、デストラクタが仮想ではない std::string を基底クラスにするのはおすすめできません。
これの意味がわからなかったので調べた。
以下のURLを参考にした。
仮想関数
C++ でデストラクタを virtual にしなくてはならない条件と理由
デストラクタが virtual でない場合、
子クラスのポインタを親クラスのポインタにキャストして使用してはいけません。
なぜならデストラクタが virtual でない場合、
親クラスの型のポインタを delete した際には親クラスのデストラクタしか呼ばれないからです。
多分、ここが本質と理解している。
子クラスのデストラクタが呼ばれないケースがありえる=メモリリークという甚大なリスクを抱えることになる。
コメントで「おそらくほとんどすべての処理系で無害」というのは、
本ページの実装では子クラスで専用のメモリ確保がないので、デストラクタが呼ばれずともメモリリークの心配はないだろうということか。
それでも推奨されないことに変わりはない。
2021/02/17 追記2
このようなコメントも頂いた。
・コンストラクタの引数は const参照がよいです。
これについては参考にした以下のページを写景した際のミス(↓こちらのコードはちゃんとconst参照となっている)。
stringを継承する
ただの凡ミスではあるが、なぜconst参照がよいのかがわかっていない。
ちゃんと理解していれば、そもそもミスらなかった可能性も十分にある。
ということで調べてみる。
いや、さすがにconstにするのはわかる。
実体渡しではなく、参照渡しにする理由を調べる
以下のページを参考にした。
C++の基礎 : const 修飾子
参照引数は実際にはポインタであるため、
大きな構造体やクラスを引数に渡すときにも効率のよい方法ですが、
const をつけない参照渡しであれば、
関数により中身を書き換えられる可能性があることになります。
参照渡しに const 修飾子をつけることにより、
引数の中身を書き換えないことを宣言することができます。
大きく以下の2つの理由だろう。
参照にする理由→大きな構造体やクラスを渡す際に効率がいい ★今回はこっちの問題に該当
constにする理由→関数内部でデータが汚染されるのを防ぐ
言われてみれば当たり前のことだった。
2021/02/17 追記3
次のコメントにいこう。
・moveコンストラクタ、move代入へのケアもあったほうが良さそうです。
正直moveコンストラクタ、move代入というワードを知らなかった。
以下のページを調べて、そういうものがあるというのはわかった。
ムーブコンストラクタ|ムーブセマンティクスやコンテナ高速化との関係
コンストラクタが継承されないので、当然moveコンストラクタも継承されない。
したがってケアが必要ということか。
2021/02/17 追記4
別の有識者からもコメントを頂いた。
どうしても継承したい場合は、非public継承とusingを使用することで、
安全性を増すことはできます。
フリー関数にしたくない場合はこちらも検討してみても良さそうです。
参考
http://cpp.aquariuscode.com/inheritance-use-case
この記事を書く上で非public継承、、といかpublic継承、private継承、protected継承がある、
というのは調べたが、もう少し突っ込んで調べた方が良さそうだ。
ということで有識者から頂いたリンクに向かった。
非PUBLIC継承の使いどころ
public継承以外はB is a Aの関係では無いため、AのポインタにBを代入することはできません。
まず、ここがポイントのようだ。
追記1で書いたように、StringExが問題になるのは、
StringExクラスのポインタをstd::stringにキャストして使用するようなケースだ。
非public継承にすれば、そもそもそれができなくなる。
しかし、そうなると当然別の問題が生じる。
StringExクラスを使う側からstd::stringクラスの機能にアクセスできなくなる。
なので、確認していないが、おそらく単体試験で示した以下のようなこと(算術演算子)ができなくなる。
それは困る。
std::cout << "str1=" + str1 << std::endl;
そもそも継承という行為自体なるべく避けるべき実装のようだ。
今回の場合、なるべくstd::stringクラスを内部で抱える実装にするべきらしい。
基本的に継承はコードの複雑性を増すので避けられるなら避けた方が望ましいとされています。
できるだけコンポジション(内包)を使って実装する方が良いでしょう。
ただ、そのように実装するにしても別の問題が生じる。
まさに以下のような内容だ。
例えばstd::vectorの拡張クラスを作りたい時に、std::vectorをメンバとして持つと、
std::vectorの持つメソッドを全て再実装しなければいけないという手間が発生します。
↓これが有識者の方が伝えたかったことのようだ。
usingを使うことで使いたい機能のみのアクセスレベルを引き上げることで、
再実装を回避しつつ、親クラスキャストを防ぐことができる。
上記のようなケースでは継承とusingを組み合わせることで実装がシンプルになります。
template <typename T>
class MyVector
: private std::vector<T, std::allocator<T>> { // STLコンテナは絶対public継承してはダメです
public:
using std::vector<T>::push_back; // usingでpush_backのアクセスレベルをpublicに引き上げる
};
2021/02/17 追記のまとめ
-
そもそも本ページで実装した機能は非メンバで実装するのが正しい。
-
それでもメンバとして実装するのであれば、少なくともデストラクタがvirtualでないstd::stringクラスをpublic継承するべきではない。
-
↑public継承するとオブジェクトのポインタを親クラスキャストした場合、子クラスのデストラクタが実行されない。
-
修正案としては、privateまたはprotected継承としてusingを用いて必要な機能のみアクセス権をpublicに引き上げる、というのが考えられる。
-
大きなクラスの引数はconst参照してリソース削減すべき
-
継承されないmoveコンストラクタとmove代入演算子のケアが必要。