C++で迷走してみる
C++は色々なことができて面白い。
それゆえ、うまくプログラムを構築するのが難しいと思うこともある。
作っているうちに、どれが最適なコーディングなのかわからなくなってしまうのだ。
今回はすごく単純なクラスを作りながら、同じ動きをするけど、どのやり方が一番いいだろうか、と考える遊びをしてみようと思う。内容的には、イディオム的な話。
注意1:本記事内では、メンバ変数のことを属性、メンバ関数のことをメソッドと呼びます。
注意2:話が長い上に迷走しまくります。結論は非常に単純ですが、自分なりに納得するところまでコーディングしてみました。
1. 下準備
2次元座標のvectorを作ってみる。
#include <vector> #include <random> struct Point2D { double x, y; }; int main(int argc, char** args) { // 0.0 〜 1.0 のランダムな座標を100個作る std::vector<Point2D> line; std::random_device rnd; std::default_random_engine rnd_engine(rnd()); std::uniform_real_distribution<double> rnd_dist(0.0, 1.0); for (int i = 0; 100 > i; ++ i) { line.push_back({rnd_dist(rnd_engine), rnd_dist(rnd_engine)}); } }
座標は単純な構造体でよいだろう。
この時点では何も問題はないように思える。
昔のC, C++と違って、乱数を発生させる標準APIが実装されているので、その辺はかなり楽ちん。今回は特に乱数アルゴリズムを指定せずに、デフォルトのエンジンを使うことにする。
さて、ここで、Point2Dクラスに「2点間の距離を求める」メソッドを追加したくなったとしよう。
#include <vector> #include <random> #include <cmath> struct Point2D { double x, y; double distance(const Point2D&) const; }; double Point2D::distance(const Point2D& to) const { return std::sqrt(std::pow(to.x - x, 2) + std::pow(to.y - y, 2)); } // メイン関数省略
できた。
では早速、この超便利なdistanceメソッドを使って、vector内のすべての点を順番に結んだときの距離を求めてみる。
2. フラグを使うという考え方
vector内の連続する座標について、1つ前の座標をb、今回の座標をcとするならば、b → c間の距離は、b.distance(c)で求められる。ここで困るのが、「最初の点には1つ前の座標bが存在しない」ということである。
こういうときに古くから使われているのは、フラグを持つというやり方である。
#include <vector> #include <random> #include <cmath> #include <iostream> // クラス定義省略 int main(int argc, char** args) { // 0.0 〜 1.0 のランダムな座標を100個作る std::vector<Point2D> line; // ランダム座標生成省略 // 点をすべて結んだ際の距離を求める double length = 0.0; bool initial = true; // 初回フラグ Point2D before; std::for_each(line.begin(), line.end(), [&](const Point2D& pt) { if (initial) { initial = false; } else { length += before.distance(pt); } before = pt; }); std::cout << length << std::endl; }
どうにも、ループ内で毎回initialフラグをチェックするのが煩わしい。
3. ポインタを使うという考え方
要は「1つ前の座標が存在しない」ということである。私のC言語脳では、「存在しない」というものに対して、NULLポインタを使いたくなる心理が働く。それを反映したのが以下のコードだ。
// クラス定義省略 int main(int argc, char** args) { // 0.0 〜 1.0 のランダムな座標を100個作る std::vector<Point2D> line; // ランダム座標生成省略 // 点をすべて結んだ際の距離を求める double length = 0.0; const Point2D* before = nullptr; std::for_each(line.begin(), line.end(), [&](const Point2D& pt) { if (before) { length += before->distance(pt); } before = &pt; }); std::cout << length << std::endl; }
ああ、そう、フラグ変数は不要になった。しかし、本質的には何も変わっていない。ループの中で毎回初回チェックを行っているのだから。とあるバスケ部の監督に言わせれば、「まるで成長していない・・・」というやつだ。
4. Null Objectパターンという考え方
もちろん、C++はオブジェクト指向言語である。オブジェクト指向には、Null Objectパターンという設計手法がある。「存在しない」オブジェクトを表すのに、ゼロポインタなんて使わずに、「存在しないオブジェクトとしての挙動を持たせよう」という考え方だ。
デザインパターンに抵抗があるひとは、深く考えずともよいと思う。ソースコードを一切出さずに、線と四角と矢印の図面だけでプログラムの設計を解説しようとしている本なんて読む価値はないのだ。今から私が簡単にNull Objectを作ってみせようじゃないか。
着想としては、あるクラス(今はPoint2D)の、全メソッド(今はdistanceだけ)に注目して、「存在しない」状態のオブジェクトが何をすべきかを考える。これをすべて定義できるのであれば、Null Objectは作れる。今回の場合でいえば、存在しない点と他の点との距離は0である。即ち、「存在しない点」というクラスを作って、そのクラスのdistanceメソッドが0を返すようにすればよいのだ。
単純に実装しようとすると、以下のようになる。が、これは不完全なやり方である。
【ダメなやり方】
struct Point2D { Point2D(double x, double y) : _x(x), _y(y) {} double _x, _y; virtual double distance(const Point2D&) const; }; double Point2D::distance(const Point2D& to) const { return std::sqrt(std::pow(to._x - _x, 2) + std::pow(to._y - _y, 2)); } struct NullPoint2D final : public Point2D { NullPoint2D() : Point2D({NAN, NAN}) {} double distance(const Point2D&) const override {return 0.0;} };
どこが不完全なのか。
int main(int argc, char** args) { NullPoint2D np; Point2D pt(1.2, 3.4); std::cout << np.distance(pt) << std::endl; // 0 std::cout << pt.distance(np) << std::endl; // nan }
このように、どちらのメソッドを呼び出すかによって結果が変わってしまう。いわゆる「対称性」というやつが失われている。本来は、2点間のどちらかがNULLであれば、0を返すのが正しい。
修正してみようか。
struct Point2D { Point2D(double x, double y) : _x(x), _y(y) {} double _x, _y; virtual double distance(const Point2D&) const; protected: virtual bool is_null() const {return false;} }; double Point2D::distance(const Point2D& to) const { if (is_null() || to.is_null()) { return 0.0; } return std::sqrt(std::pow(to._x - _x, 2) + std::pow(to._y - _y, 2)); } struct NullPoint2D final : public Point2D { NullPoint2D() : Point2D({NAN, NAN}) {} protected: bool is_null() const override {return true;} };
これで対称性の問題は回避した。
しかしまだ問題がある。
int main(int argc, char** args) { NullPoint2D np; Point2D pt1(1.2, 3.4); Point2D pt2(5.6, 7.8); pt1 = np; std::cout << pt1.distance(pt2) << std::endl; // nan std::cout << pt2.distance(pt1) << std::endl; // nan }
座標を代入しているうちに、Null Objectを代入してしまって、そのオブジェクトからある点への距離を取ろうとしたら、本来0となるべきところがNaNになってしまった件。これは使い方が悪いといえばそうなのだが、間違いが起こりやすいという点は明らかに問題であろう。
ある座標に対して、存在しない座標を代入しようとしたらどうなるべきだろうか。
もし仮に、pt1 = pt2;について、「pt2がNull Objectであるならば、代入した結果、pt1もNull Objectになるべきだ」とするならば、ポインタを使えばそのように動く。
int main(int argc, char** args) { std::shared_ptr<Point2D> pt1(new Point2D(1.2, 3.4)); std::shared_ptr<Point2D> pt2(new Point2D(5.6, 7.8)); std::shared_ptr<Point2D> pt3(new NullPoint2D()); pt1 = pt3; // 値の代入ではなく、ポインタの参照先の切り替え std::cout << pt1->distance(*pt2) << std::endl; // 0 std::cout << pt2->distance(*pt1) << std::endl; // 0 }
通常の代入(operator=)では、Point2Dの変数自体をNullPoint2Dのインスタンスに切り替えることはできない。operator=は値の代入であって、参照の切り替えではない。C++で多態性を使おうと思った場合、ポインタや参照を使うのは定石だ。
しかし、まあ・・・、これはやはり間違いやすいのではなかろうか。
APIの利用者が必ずポインタでPoint2Dクラスを使ってくれるとは限らない。むしろ、Point2Dの役割、性質から見て、値渡しやoperator=を使いたくなる方が自然であろう。
5. やめだやめだ
値クラスにNull Objectパターンを使おうとした私が間違っていた。
ちなみに、
この画像は、先のプログラムで生成した100個のランダムな点を順に結んで、iPhoneの座標系に変換して表示したものだ。
まるで私の頭の中を表現しているかのようにグチャグチャじゃないか。
なめやがって・・・
値クラスに対して、値が存在しないことを示すには、やはりNULLポインタを使うのが自然な気がしてきた。要は、「1個前の座標があるかないか」、「あるならそれを使う」、「ないならNull Object的な挙動をする」であれば良いのだ。スマートポインタ的なのを、newだのdeleteだのなしで使えるようにしてやればよいのではないだろうか。
#include <vector> #include <random> #include <cmath> #include <iostream> // 2次元座標クラス // 2次元座標クラス自体には、「存在しない点」という概念はない。 class Point2D { public: Point2D(double x, double y) : _x(x), _y(y) {} double distance(const Point2D& to) const { return std::sqrt(std::pow(to._x - _x, 2) + std::pow(to._y - _y, 2)); } double x() const { return _x; } double y() const { return _y; } bool operator==(const Point2D& other) const { return x() == other.x() && y() == other.y(); } private: bool operator<(const Point2D& other) const; bool operator>(const Point2D& other) const; bool operator<=(const Point2D& other) const; bool operator>=(const Point2D& other) const; private: double _x, _y; }; // 2次元座標参照クラス // Point2D変数へのポインタを保持する。 // このクラスには、「参照先が存在しない」という概念がある。 // 参照先が存在する場合には、そいつを使う。 // 参照先が存在しない場合には、Null Object的な振る舞いをする。 class PointRef { public: PointRef() : ptr(nullptr) {} PointRef(const Point2D* p) : ptr(p) {} const Point2D& get() const {return *ptr;} double distance(const Point2D& to) const { return ptr ? ptr->distance(to) : 0.0; } PointRef& operator=(Point2D* p) {ptr = p; return *this;} PointRef& operator=(PointRef r) {ptr = r.ptr; return *this;} bool operator==(const Point2D& other) const { return ptr ? ptr->operator==(other) : false; } private: bool operator<(const Point2D& other) const; bool operator>(const Point2D& other) const; bool operator<=(const Point2D& other) const; bool operator>=(const Point2D& other) const; private: const Point2D* ptr; }; int main(int argc, char** args) { // 0.0 〜 1.0 のランダムな座標を100個作る std::vector<Point2D> line; std::random_device rnd; std::default_random_engine rnd_engine(rnd()); std::uniform_real_distribution<double> rnd_dist(0.0, 1.0); for (int i = 0; 100 > i; ++ i) { line.push_back({rnd_dist(rnd_engine), rnd_dist(rnd_engine)}); } // 点をすべて結んだ際の距離を求める double length = 0.0; PointRef before; std::for_each(line.begin(), line.end(), [&](const Point2D& pt) { length += before.distance(pt); before = &pt; }); std::cout << length << std::endl; }
ハァハァ・・・
これが正しい姿のはず・・・
結果的には、「3. ポインタを使うという考え方」のNULLチェックを別のクラスに分離したというだけだ。だがそれでいい。メインロジックとなる部分にNULLチェックのif文が存在しないということが重要なのだ。ちなみに今回のはデザインパターンでいうところのAdaptorパターンだが、もう、そんなことはどうでもいい。疲れたよパトラッシュ。
今回の2次元座標クラス自体には、「存在しない」という概念はない。
存在しない座標はインスタンス化されないのだ。
xやyの値がNaNになるのは、そういう座標計算を行ったときであり、xやyの値がNaNだからといって、その座標の実体が存在しないわけではない。そいつは、計算できないドコカに在ル!
「存在しない」という今回の事件が起こるのは、一連の座標の前後を参照しようとしたときである。
「この座標の前は存在しない」、「この座標の後は存在しない」
そういう考え方に基づいた実装にしてみた。
これなら、Point2Dに対して、間違えてPointRefを代入してしまうこともないし(対応するoperator=がないのでコンパイルエラーになるし、やろうと思えばget()でポインタを取得して、そこから値を取ることもできる)、逆にPointRefに対して、Point2D(のアドレス)を代入することは、参照先の切り替えという意味できちんと機能する。
最後のfor_eachに注目していただきたい。
余計なフラグも存在しないし、NULLチェックも行われていない。
これをやりたかったんだ・・・。
こうしてポインタアダプターパターンという私独自のデザインパターンが完成したわけである。
めでたしめでたし。
6. 本当にそんなクラス必要?
うん・・・本当は要らないって、知ってるんだ。
フラグだのデザインパターンなどの知識が私の脳を鈍らせ、迷走させた。
今回の話は、「最初の点を結ぶときだけ1個前の点が存在しない」ことが問題だったのであって、そのことだけに注目すれば、人間の頭でもっと合理的なやり方を導くことができる。
そう、2点目から数え始めればいいのだ。
なんて単純なのだろう。
これはもう、プログラムの知識というより、単純な発想の問題。
こんな簡単なことに気づかないから、この記事の1〜5のような迷走をしてしまうのだ。
#include <vector> #include <random> #include <cmath> #include <iostream> struct Point2D { double x, y; double distance(const Point2D&) const; }; double Point2D::distance(const Point2D& to) const { return std::sqrt(std::pow(to.x - x, 2) + std::pow(to.y - y, 2)); } int main(int argc, char** args) { // 0.0 〜 1.0 のランダムな座標を100個作る std::vector<Point2D> line; std::random_device rnd; std::default_random_engine rnd_engine(rnd()); std::uniform_real_distribution<double> rnd_dist(0.0, 1.0); for (int i = 0; 100 > i; ++ i) { line.push_back({rnd_dist(rnd_engine), rnd_dist(rnd_engine)}); } // 点をすべて結んだ際の距離を求める double length = 0.0; if (line.begin() != line.end()) { Point2D before = *line.begin(); std::for_each(line.begin() + 1, line.end(), [&](const Point2D& pt) { length += before.distance(pt); before = pt; }); } std::cout << length << std::endl; }
これが正解だろうなー。
まあ、点が2点以上存在するかどうかのif文が不要という点で、もしも同じようなことを何度もするならば、PointRefクラスも悪くはないだろう。ただ、ループ内に本当の意味でif文が存在しないのは、私が提示した中では6のやり方だけである。そして、今回のお題を実装するのに一番記述量が少なくて、かつ、理論的に一番高速なのが、おそらく、この最後の6のやり方である。
ということで
だいぶgdgdになりましたが・・・スッキリしたので終わります。
それでは。
楽しい時間でした!
またの機会を!