はじめに
この記事は,メソッドチェーンを実現するための「よくあるコード」に対し疑問を呈し考えた解決策に対して,指摘していただくために作成したものです.間違い等ありましたらぜひご指摘ください.
メソッドチェーン
C++11におけるラムダ式導入等の大幅な改修以降,関数型をサポートするような変更がコア言語や標準ライブラリを含め多く加えられてきました. そんな中で未だに取り入れられていないのがパイプ演算子,オブジェクト指向言語で言うところのメソッドチェーンです.
C#においてはLinqがその機能を実現しています.
//using System;
//using System.Collections.Generic;
//using System.Linq;
List<int> lst = new List<int>() { 1, 2, 3, 4 };
var ary = lst
.Append(5)
.Select(s => s * 2)
.Where(s => s % 3 != 0)
.ToArray();
foreach(var x in ary) {
Console.WriteLine(x);
}
//2
//4
//8
//10
メソッドチェーンのメリットはフローが明確にできるのと,一時変数をつくらなくてよいことにあります.特にこのフローを明確にできる,というのがコーディングにおいて強力に働きます.
C++におけるメソッドチェーン
このようにメリットの大きいメソッドチェーンをC++で実現するために,よくあるコードとして以下のようなものが考えられています.
class MethodChain {
public:
MethodChain& chain1() { /*何らかの処理1*/ return *this; }
MethodChain& chain2() { /*何らかの処理1*/ return *this; }
MethodChain& chain3() { /*何らかの処理1*/ return *this; }
};
MethodChain mc;
mc.chain1().chain2().chain3();
参照を返しているのがポイントです.これによって,チェーンを作ったときにコピーが発生せず高速なままでフローを表すことができます.しかしこのコードには問題点があり,メソッドチェーンを書く際によく出でくるような書き方をした場合に安全でない可能性があります.
問題点
上のクラスを使って以下のようなコードを考えてみましょう.
MethodChain mc;
const MethodChain& rmc1 = mc.chain1().chain2().chain3(); //(1)
const MethodChain& rmc2 = MethodChain().chain1().chain2().chain3(); //(2)
(1)に関しては,オブジェクトの寿命という観点に注目すれば,mcが左辺値であり(1)以降も破棄されないので安全です.一方で(2)はどうでしょうか.chain3()の戻り値が左辺値参照であるため,残念ながらconst参照による一時オブジェクトの延命は起こりません.そのためrmc2は不正な参照となりこの変数へのアクセスは未定義動作となります.
こんなコードは誰も書かないと思われるかもしれません.しかし次のようなコードで気づきにくい問題が発生します.
template <class T>
struct enumerable {
template <class Container>
enumerable(const Container&) { ... return *this; }
enumerable<T>& append(T) { ... return *this; }
template <class Func>
enumerable<T>& select(Func) { ... return *this; }
private:
...
};
for(auto x : enumerable(std::vector<int> { 1, 2, 3 })
.append(4)
.select([](int x){ return x * 2; })) {
std::cout << x << std::endl; // undefined behavior
}
範囲for文ではrange-initializer
をユニバーサル参照で受け取ります.つまりrange-initializer
が左辺値の場合参照先の寿命が延長されず左辺値として受け取られるわけですから,内部で行われるイテレータの更新では不正な参照が使われることになります.こういった点から,参照を返すような設計は好ましくないと思います.
解決策
単純な解決策としては呼び出し元のオブジェクトが右辺値だった場合戻り値を参照ではなく値とすることです.右辺値か左辺値かの判断はメンバ関数に参照修飾を行なえば出来ます.しかし単に値を返すだけでは無駄にコピーが発生するため,メソッドチェーンを構築するクラスが状態等を持っていた場合,特にLinqのような機能を実現するために内部で大サイズのコンテナを持っていた場合,効率が悪くなります.これに関しては自身をムーブすれば解決できます.以上のことをまとめると以下のようなコードになります.
template <class T>
struct enumerable {
template <class Container>
enumerable(const Container&) { ... }
enumerable(const enumerable<T>&) { ... }
enumerable(enumerable<T>&&) { ... }
enumerable<T>& append(T) & { ... return *this; }
enumerable<T> append(T) && { ... return std::move(*this); }
template <class Func>
enumerable<T>& select(Func) & { ... return *this; }
template <class Func>
enumerable<T> select(Func) && { ... return std::move(*this); }
private:
...
};
for(auto x : enumerable(std::vector<int> { 1, 2, 3 })
.append(4)
.select([](int x) { return x * x; })) {
std::cout << x << std::endl; // OK
}
ただ,呼び出したオブジェクトが左辺値か右辺値かで変わる処理がそのままreturnかムーブかの違いだけなので冗長さが否めません.またこのコードにテンプレートパラメータの制約などを加えると更にその冗長さが増します.この点が何かいい案がないか考えたのですが思いつきませんでした.
まとめ
今回はメソッドチェーンに隠れた未定義動作とその解決策について扱いましたが,C++20以降ではrangesライブラリによりLinqのようなメソッドチェーンの使い方は役目を終えたと思います.また,LinqCppといったライブラリも存在するのでC++17以前であっても自分でLinq的なものを作る必要は無いと言えます.ただ,自作クラスの初期化や状態遷移等を表すためのメソッドチェーンなど用途はあるので,それらの実装の際には注意する必要があります.