Gemini覚書

 

C = A + B を式テンプレートで書くには?

 

C = A + B」のような記述を式テンプレートで実現する場合、直接「C=A; C+=B;」のように分解して書くわけではありません。式テンプレートの目的は、A+B という式そのものを、その場で計算するのではなく、計算方法を記述したオブジェクトとして表現することにあります。

非常に簡略化された例で見てみましょう。ここでは、2つのベクトルを加算するケースを考えます。

 

template<typename T, size_t N>
class Vector {
public:
    T data[N];

    // コンストラクタなど省略...

    // 演算子+のオーバーロード(式テンプレートの核)
    // 戻り値の型がポイント!Vector<T, N> ではなく、式を表す型を返す
    template<typename OtherVector>
    VectorSum<Vector, OtherVector> operator+(const OtherVector& other) const {
        return VectorSum<Vector, OtherVector>(*this, other);
    }

    // 代入演算子(ここで実際の計算が走る)
    template<typename Expr>
    Vector& operator=(const Expr& expr) {
        for (size_t i = 0; i < N; ++i) {
            data[i] = expr[i]; // expr[i] が A[i] + B[i] を評価する
        }
        return *this;
    }

    // 式オブジェクトから値を取得するためのアクセス演算子
    // これが「遅延評価」の仕組みの肝
    T operator[](size_t i) const {
        // この Vector は計算済みデータを持つので、そのまま返す
        return data[i];
    }
};

// ベクトルの和を表す式オブジェクトのクラス
// これは A+B の「計算のレシピ」を持つ
template<typename LhsVector, typename RhsVector>
class VectorSum {
public:
    const LhsVector& lhs;
    const RhsVector& rhs;

    VectorSum(const LhsVector& l, const RhsVector& r) : lhs(l), rhs(r) {}

    // []演算子でアクセスがあったときに初めて計算を実行
    // 例: C[i] = (A+B)[i] の (A+B)[i] の部分
    auto operator[](size_t i) const {
        return lhs[i] + rhs[i]; // ここで実際に A[i] + B[i] が計算される
    }
    // サイズを返すための型エイリアスやメソッドも必要
    // static constexpr size_t size = LhsVector::size; など
};

// 実際の使用例
int main() {
    Vector<double, 3> A = {1.0, 2.0, 3.0};
    Vector<double, 3> B = {4.0, 5.0, 6.0};
    Vector<double, 3> C;

    C = A + B; // ここがポイント!

    // Cが計算された後、C.dataは {5.0, 7.0, 9.0} となる
    // for (size_t i = 0; i < 3; ++i) {
    //     std::cout << C.data[i] << std::endl;
    // }
    return 0;
}
 

この例の肝は以下の点です。

  1. Vector::operator+ の戻り値: Vector 型を直接返さず、VectorSum という式を表すオブジェクトを返しています。この VectorSum オブジェクトは、AB への参照を持ち、「AB を足し合わせる」という計算のレシピを保持しています。この時点ではまだ実際の加算は行われていません。

  2. Vector::operator= の実装: C = A + B= 演算子が呼ばれた際に、右辺の (A + B) が先ほどの VectorSum オブジェクトになります。この operator= の中でループを回し、expr[i](この場合は VectorSum オブジェクトの operator[])を呼び出すことで、初めて A[i] + B[i] の計算が実行され、その結果が C.data[i] に直接代入されます。

 

なぜ「単一のループで計算を行うことができ、より効率的になる可能性」があるのか?

 

上記の例では、C = A + B という式全体が、Vector::operator= 内の単一のループで処理されています。

もし式テンプレートを使わない場合、C = A + B は以下のように処理されることが多いでしょう(一般的な operator+ の実装を想定)。

// 従来のVectorクラス(簡易版)
template<typename T, size_t N>
class TraditionalVector {
public:
    T data[N];
    // コンストラクタなど省略...

    // 演算子+のオーバーロード(中間オブジェクトを生成)
    TraditionalVector operator+(const TraditionalVector& other) const {
        TraditionalVector result; // 1. 中間オブジェクトの生成!
        for (size_t i = 0; i < N; ++i) {
            result.data[i] = data[i] + other.data[i]; // 2. 最初のループ
        }
        return result; // 3. 中間オブジェクトを返す
    }
    // 代入演算子(コピーコンストラクタ経由で呼ばれることも)
    TraditionalVector& operator=(const TraditionalVector& other) {
        // 4. ここで中間オブジェクトの内容をCにコピー
        for (size_t i = 0; i < N; ++i) {
            data[i] = other.data[i];
        }
        return *this;
    }
};

// 従来のやり方での使用例
// TraditionalVector<double, 3> A, B, C;
// C = A + B;
 

この従来の方式では:

  1. A + B の計算のために、一時的な TraditionalVector オブジェクト (result) が生成されます。これはメモリの確保や初期化のオーバーヘッドになります。

  2. A + B の計算のために一度目のループが走ります。

  3. その一時オブジェクトが Cコピーされます。これにも二度目のループが走ることがあり、さらにメモリコピーのオーバーヘッドが発生します。

一方、式テンプレートを使った場合は:

  • A + B の時点では計算を行わず、計算方法を表す軽量なオブジェクトVectorSum)を生成するだけです。

  • C = (A + B) の代入演算子内で、A[i] + B[i] の計算と C[i] への代入を単一のループ内で同時に行います。

これにより、中間オブジェクトの生成と、それに伴うメモリ割り当て・コピーのオーバーヘッド、そしてループの複数回の実行を削減できるため、効率が向上します。特に大規模な行列やベクトルを扱う場合、この差は顕著になります。

 

式テンプレートはコンパイラへの「命令文」のようなブラックボックスか?

 

「式テンプレート風に書くと、コンパイラが式テンプレートを認識して、機械語を吐く一種の命令文のようなブラックボックスでしょうか?」というご質問ですが、半分正解で半分は違います。

  • 「一種の命令文のようなブラックボックス」ではない:式テンプレートは、C++の標準的なテンプレートと演算子オーバーロードの機能を使って実装されています。コンパイラが「式テンプレート」という特別な構文を認識しているわけではありません。コンパイラは、単にテンプレートのインスタンス化ルールと、ユーザーが定義した演算子のオーバーロードのルールに従って、コードを生成しているだけです。

  • 「コンパイラが認識して機械語を吐く」という点では近い:しかし、結果としてコンパイラは、上記で説明したような中間オブジェクトの生成を省き、単一の効率的なループに「展開された」機械語を生成します。これは、プログラマが手書きで書くような、最適化されたループ(例:for (size_t i = 0; i < N; ++i) { C.data[i] = A.data[i] + B.data[i]; })に相当するコードを、C = A + B というより自然な構文で書けるようになる、という点で「コンパイラに対する高度な指示」と捉えることもできます。

つまり、式テンプレートは、コンパイラの通常のメカニズムを巧みに利用して、開発者が意図する高度な最適化(遅延評価とループフュージョンなど)を、より高いレベルの抽象度で記述できるようにするデザインパターンと言えます。


この説明で、式テンプレートの実装と最適化の理屈が少しでもクリアになれば幸いです。式テンプレートは強力ですが、実装は複雑でデバッグも難しいため、通常はBoost.uBLASのような既存のライブラリを利用するのが一般的です。