読者です 読者をやめる 読者になる 読者になる

処理の連鎖をどう書くか? - 演算子方式とメソッドチェイン方式

.NET

算術演算の拡張

普段あまり意識しないかもしれませんが,算術演算も「処理の連鎖を組み合わせて大きな処理を書く」の一種です.個々の演算で引数の型と戻り値の型がどう対応するかは事前に決まっていて,式全体の型はその組み合わせから自然に導出されます.

var r = (((1 + 1) - 100) * 1.0f) / 10;

C# の算術演算は基本的に C/C++ のそれをそのまま拝借しているのでなじみやすいのですが,いくつか拡張している部分もあります.例えば C# 2.0 で導入された Nullable 拡張などがそれです.

var r = (((x + 1) - 100) * y) / 10;
xの型 yの型 rの型
int int int
float int float
int float float
int? float float?
float int? float?

C# の Nullable 拡張演算では,片方が null だったら演算結果も null というルールがあって,結果的に一方の型が Nullable であれば戻り値の型も Nullable になります*1.ルールがあるのだから,他の言語でもうまくやれば「型にまたがった記述」を使って実装できそうなものです.実際 C++ では,演算子オーバーロードに template が使えますし,コンパイラを弄らなくてもライブラリレベルの対応で十分カバーできるんじゃないでしょうか.噂の Expression Template あたりで.

連鎖と generic な演算子

C# では演算子オーバーロードに generics を使うことができませんが,C++/CLI では可能です.これを利用して,先日書いた Maybe monad の処理の記述に演算子を使ってみましょう.

using namespace System;
using namespace System::Diagnostics;

generic <typename T>
[DebuggerDisplay("{IsNothing ? (object)\"Nothing\" : (object)Unwrap()}")]
public value class Maybe
{
private:
    initonly T Value;
    bool hasValue;
public:
    Maybe(T value)
    {
        Value = value;
        hasValue = (value != nullptr);
    }
    property bool IsNothing { bool get() { return !hasValue; } }
    T Unwrap() { return Value; }
    generic <typename U>
    Maybe<U> Select(Func<T, U>^ func)
    {
        return IsNothing ? Maybe<U>() : Maybe<U>(func(Unwrap()));
    }
    generic <typename U>
    Maybe<U> SelectMany(Func<T, Maybe<U>>^ func)
    {
        return IsNothing ? Maybe<U>() : func(Unwrap());
    }
    generic <typename U, typename V>
    Maybe<V> SelectMany(Func<T, Maybe<U>>^ func, Func<T, U, V>^ resultSelector)
    {
        if( IsNothing ) return Maybe<V>();
        T rawT = Unwrap();
        Maybe<U> temp = func(rawT);
        return temp.IsNothing ? Maybe<V>() : Maybe<V>(resultSelector(rawT, temp.Unwrap()));
    }
    generic <typename U>
    Maybe<U> operator |(Func<T, U>^ func)
    {
        return Select<U>(func);
    }
    generic <typename U>
    Maybe<U> operator |(Func<T, Maybe<U>>^ func)
    {
        return SelectMany<U>(func);
    }
    virtual String^ ToString() override
    {
        return IsNothing ? L"[" + T::typeid->Name + "] Nothing" : Unwrap()->ToString();
    }
};

こんな感じで Maybe 構造体に generic な | 演算子を定義しておくと,C++/CLI では以下のような書き方が可能になります.

Maybe<String^> str
    = Maybe<Random^>(gcnew Random())
        | gcnew Func<Random^,int>(&Random::Next)
        | gcnew Func<int,String^>(&Convert::ToString)
        | gcnew Func<String^,String^>(&String::Normalize)
        | gcnew Func<String^,String^>(&String::ToLower);

まあご覧の通りラムダ式が足らないので,このままでは微妙に苦しいところもあるのですが.もうちょっと弄ってラムダ式っぽく書けるようにする必要があるでしょう.

演算子を利用した連鎖の記述と,メソッドチェインを使った連鎖の記述

なぜ C# でメソッドチェインが楽しいのかを考えてみた - NyaRuRuの日記』で扱った連鎖の性質をもう一度思い出します.

  • 処理の前後で Interface<(何か)> という大枠は変わらない
  • (何か) の型は処理の前後で変化してよい

この性質は多くの算術演算にも当てはまりそうです.演算の結果,結果の型の (何か) の部分が変化しているかもしれませんが,さらに算術演算を連鎖させられるという大枠に乗っているという点では同じと言えます.
とまあ連鎖の記法にとしてメジャーなものに,演算子方式とメソッドチェイン方式の 2 種類があるということになります.ちょっとその使い分けを考えてみましょう.

演算子を利用した連鎖の記述

一般に,多数の算術演算が存在し,元の演算子の優先順位を維持したい場合は,演算子を利用した拡張が便利そうです.Nullable な算術型同士の Null propagation や,整数上の演算を Matrix 型上の演算に拡張する場合などがそれでしょうか.

var matrix = matA + matB * matC;

これとは別に,演算子を区切り的につかって処理を連鎖させるスタイルも根強い人気があるようです.
前回引用した Perl6 での記法.

dataset1 ==> where(<category> == targetCategory)
         ==> select(<name>, <price>)
         ==> calc(<price> *= 0.05)
         ==> sum(<name>)
         ==> printf("%s %d\n", <name>, <price>)

PowerShell で SIGPIPE 連鎖 - NyaRuRuの日記』で示した PowerShell での記法.

yes | take 10000 | take 1

oven も | 演算子派のようですね.
この他,C/C++ の conditional operator の連鎖や,C# での coalesce operator (?? 演算子) の連鎖にも似た雰囲気を感じます.
以下 conditional operator の連鎖.

int g(std::string const& str)
{
  return str == "foo" ? 1:
         str == "bar" ? 2:
         str == "baz" ? 3:
                        0;
}

以下 C# での ?? 演算子 の連鎖.

var ret = a ?? b ?? c ?? d ?? string.Empty;

メソッドチェインを利用した連鎖の記述

メソッドチェインが便利なのは,一般に,Interface<(何か)> という大枠に対してよく行う処理がいくつか事前に分かっていて,それに名前を付けておきたいという状況でしょうか.名前を付けておけば IntelliSense ですぐに選べますしね.演算子による連鎖ではこうはいきません.少なくとも Visual Studio では.
メソッドチェイン形式を採用している例.

ただの足し算や引き算をメソッドチェインで書くのは大仰しすぎるところがありますが,行列スタックなどでは「名前付き」の処理を連鎖させた方が書きやすかったのを憶えています.回転や平行移動などですね.もちろん対応する行列を作って掛け合わせても同じことなのですが.最初からよく使う処理が分かっているならば,名前を与えてしまうのも悪くないということでしょう.

C# での,演算子を利用した連鎖の記述と,メソッドチェインを使った連鎖の記述の使い分け

C# の場合,既に存在する演算子であればもちろん連鎖の記述が可能ですが,演算子の意味を拡張して汎用的な連鎖記法を導入するのはなかなか難しいという印象があります.やはり演算子オーバーロードに generics を使えないというのが大きな制約になります.他言語では演算子による記法が綺麗に行っている場合でも,それを C# に輸入するのが難しいという場面に私もたびたび出会いました.この場合は相性と思ってあきらめて,C# 以外の言語を使ったり,専用の DSL を設計したりするのが良いかもしれません.
一方,メソッドチェインを用いる連鎖記法は C# と非常に相性がよいです.Visul C# の IntelliSense が非常に安定して強力なこと,メソッド形式であれば,generics やラムダ式をフルに活用できることなどが理由です.
結論としては,C# で連鎖の記法を考えるとき,

  • メソッドチェインが相応しい場面であれば,メソッドチェインで設計する.
  • メソッドチェインよりも演算子を使った連鎖の方が適していると感じたなら,そこは相性が悪いのでそれ以上無茶をしない.

という感じでしょうか.

おまけ: C++ での演算子を利用した連鎖の記述と,メソッドチェインを使った連鎖の記述の使い分け

C++ ではどうかというと,ストリーム演算子に始まって,記号を使った処理の連鎖が多いような印象を受けます.
とはいえ Visual C++ でも,IntelliSense はそれなりに人々に愛されているわけで,そこはメソッドチェインを使う動機になり得るかもと最近思い直しました.私も『C++/CLI で LINQ - NyaRuRuの日記』ではついつい演算子方式で連鎖記法を考えてしまいましたが,今思えばメソッドチェインでも良かったのかもしれませんね.

*1:比較演算子などはこのルールから外れている.詳しくはこちら『[http://blogs.msdn.com/ericlippert/archive/2007/06/27/what-exactly-does-lifted-mean.aspx:title=What exactly does 'lifted' mean? - Fabulous Adventures In Coding]』