Skip to content

Expression templatesのダングリング対策について

本記事では、expression templatesの概要を述べ、欠点の一つであるダングリングする可能性に対処する方法を示します。

Expression templatesとは

数値線形代数で最初に使われた手法で、構文木をテンプレートを使って擬似的に作ります。 利点は、一時変数の作成コストを削減できる点です。

例えば、3次元ベクトルa, b, cに対して、

Vec3 a, b, c;
sum = a + b + c;

を計算しようとすると、まずtmp = a + bが計算され、次にsum = tmp + cが計算されるでしょう。 Expression templatesでは、この計算を

for (int i = 0; i < 3; ++i) {
  sum[i] = a[i] + b[i] + c[i];
}

のように一括で行うことで一時変数を作らなくて済むようにします1。 実際に、Eigenで使われています。

欠点は、コンパイル時のエラーメッセージが表現が複雑になるほど読みにくくなることとダングリングする可能性があることです。

Expression templatesの実装例

Expression templatesの実装例として、実際に私が書いたコードの一部を抜粋し、改変しました。

template <class L, class Op, class R>
class BinaryExpr final {
 public:
  static_assert(std::is_same_v<typename L::Domain, typename R::Domain>, "Domain mismatch!");
  static_assert(std::is_same_v<typename L::Range, typename R::Range>, "Range mismatch!");

  using Domain = typename L::Domain;
  using Range  = typename L::Range;

  BinaryExpr() = delete;

  BinaryExpr(const BinaryExpr&)            = default;
  BinaryExpr(BinaryExpr&&)                 = default;
  BinaryExpr& operator=(const BinaryExpr&) = default;
  BinaryExpr& operator=(BinaryExpr&&)      = default;
  ~BinaryExpr()                            = default;

  BinaryExpr(const L& l, const R& r) : l_(l), r_(r) {}

  // Calculate x of this
  Range operator()(const Domain& x) const { return Op::Apply(l_(x), r_(x)); }

  const auto& read_l() const { return l_; }
  const auto& read_r() const { return r_; }

 private:
  const L& l_;
  const R& r_;
};

struct Plus final {
  template <class R>
  requires(!std::is_floating_point_v<R>)
  static R Apply(R&& l, R&& r) {
    return std::forward<R>(l) + std::forward<R>(r);
  }

  template <class R>
  requires std::is_floating_point_v<R>
  static R Apply(R l, R r) {
    return l + r;
  }
};

struct Multiply final {
  template <class R>
  requires(!std::is_floating_point_v<R>)
  static R Apply(R&& l, R&& r) {
    return std::forward<R>(l) * std::forward<R>(r);
  }

  template <class R>
  requires std::is_floating_point_v<R>
  static R Apply(R l, R r) {
    return l * r;
  }
};

BinaryExprのテンプレート引数Opは二項演算を表し、PlusMultiplyが代入されることを想定しています。 LRは関数オブジェクトを想定しています。 そして、operator()で受け取った引数xlrに渡して、Op::Apply関数が呼び出し、計算結果が返されます。

ついでに、関数クラスも作りましょう。

class F {
public:
    using Domain = double;
    using Range = double;

    template <class T>
    F(T&& f) : f_(f) {}

    double operator()(double x) const {
        return f_(x);
    }

private:
    std::function<double(double)> f_;
};

この実装例は大体うまく行きます。

lまたはrが一時オブジェクトじゃなければ。

ダングリングへの対処法

C++では、「一時オブジェクトtmpconst&の変数xに代入すると、tmpの寿命がxの寿命に延長される」という仕様があります。 しかし、このxを別のconst&の変数yに代入しても、tmpの寿命はxの寿命と同じままです2

そのため、以下のようなコードはダングリングポインタを発生させます。

BinaryExpr<F, Plus, F>{
  [](double x){ return x + 1; },
  [](double x){ return x * x; }
}

では、どうするか?

私が考えた対処法は演算対象の型(LR)のラッパークラスを作成し、演算対象の型がconst&があるか&&があるかによって、ラッパークラスの実装を切り変えるという方法です。

具体的には、以下の型を作ります。

template <class T>
constexpr bool is_const_v = std::is_const_v<std::remove_reference_t<T>>;

template <class T>
constexpr bool is_const_ref_v = is_const_v<T> && std::is_lvalue_reference_v<T>;

template <class T>
constexpr bool is_rvalue_v = std::is_rvalue_reference_v<T> && (!is_const_v<T>);

template <class T>
constexpr bool is_cref_or_rvalue_v = is_const_ref_v<T> || is_rvalue_v<T>;

template <class T>
struct FilterRvalue;

template <class T>
struct FilterRvalue<T&&> {
  using type = T;

  static constexpr bool is_rvalue = true;
};

template <class T>
struct FilterRvalue<const T&> {
  using type = const T&;

  static constexpr bool is_rvalue = false;
};

template <class T_>
requires is_cref_or_rvalue_v<T_>
class RefWrapper final {
  using FilterResult = FilterRvalue<T_>;

 public:
  using T = std::remove_cvref_t<T_>;

  using Domain = typename T::Domain;
  using Range  = typename T::Range;

  using Storage = FilterResult::type;

  static constexpr bool is_rvalue = FilterResult::is_rvalue;

  RefWrapper() = delete;

  RefWrapper(const RefWrapper&)            = default;
  RefWrapper(RefWrapper&&)                 = default;
  RefWrapper& operator=(const RefWrapper&) = default;
  RefWrapper& operator=(RefWrapper&&)      = default;
  ~RefWrapper()                            = default;

  RefWrapper(T_&& t) : t_(std::forward<T_>(t)) {}

  Range operator()(const Domain& x) const { return t_(x); }

  const Storage& read() const
  requires(!is_rvalue)
  {
    return t_;
  }

  const Storage& read() const
  requires(is_rvalue)
  {
    return t_;
  }

  const Storage& move() &&
  requires(!is_rvalue)
  {
    return t_;
  }

  Storage move() &&
  requires(is_rvalue)
  {
    return std::move(t_);
  }

 private:
  Storage t_;
};

大まかに解説すると、const&&&を判別するコンパイル時定数を作成し、const&ならそのまま、&&ならムーブして値を保持するクラスがRefWrapperです。 値をムーブできるようにmoveメンバ関数を用意しています。

これを先ほどのBinaryExprに組み込むと以下のようになります:

src/expr_template/example.cpp
//  Copyright 2023,2025 Yuya Asano
//
//  Licensed under the Apache License, Version 2.0 (the "License");
//  you may not use this file except in compliance with the License.
//  You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
//  Unless required by applicable law or agreed to in writing, software
//  distributed under the License is distributed on an "AS IS" BASIS,
//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//  See the License for the specific language governing permissions and
//  limitations under the License.

#include <concepts>
#include <functional>
#include <type_traits>

template <class T>
constexpr bool is_const_v = std::is_const_v<std::remove_reference_t<T>>;

template <class T>
constexpr bool is_const_ref_v = is_const_v<T> && std::is_lvalue_reference_v<T>;

template <class T>
constexpr bool is_rvalue_v = std::is_rvalue_reference_v<T> && (!is_const_v<T>);

template <class T>
constexpr bool is_cref_or_rvalue_v = is_const_ref_v<T> || is_rvalue_v<T>;

template <class T>
struct FilterRvalue;

template <class T>
struct FilterRvalue<T&&> {
  using type = T;

  static constexpr bool is_rvalue = true;
};

template <class T>
struct FilterRvalue<const T&> {
  using type = const T&;

  static constexpr bool is_rvalue = false;
};

template <class T_>
requires is_cref_or_rvalue_v<T_>
class RefWrapper final {
  using FilterResult = FilterRvalue<T_>;

 public:
  using T = std::remove_cvref_t<T_>;

  using Domain = typename T::Domain;
  using Range  = typename T::Range;

  using Storage = FilterResult::type;

  static constexpr bool is_rvalue = FilterResult::is_rvalue;

  RefWrapper() = delete;

  RefWrapper(const RefWrapper&)            = default;
  RefWrapper(RefWrapper&&)                 = default;
  RefWrapper& operator=(const RefWrapper&) = default;
  RefWrapper& operator=(RefWrapper&&)      = default;
  ~RefWrapper()                            = default;

  RefWrapper(T_&& t) : t_(std::forward<T_>(t)) {}

  Range operator()(const Domain& x) const { return t_(x); }

  const Storage& read() const
  requires(!is_rvalue)
  {
    return t_;
  }

  const Storage& read() const
  requires(is_rvalue)
  {
    return t_;
  }

  const Storage& move() &&
  requires(!is_rvalue)
  {
    return t_;
  }

  Storage move() &&
  requires(is_rvalue)
  {
    return std::move(t_);
  }

 private:
  Storage t_;
};

// L and R may be either const& or &&.
template <class LRef_, class Op, class RRef_>
requires is_cref_or_rvalue_v<LRef_> && is_cref_or_rvalue_v<RRef_>
class BinaryExpr final {
  using LRef = RefWrapper<LRef_>;
  using RRef = RefWrapper<RRef_>;

 public:
  using L = std::remove_cvref_t<LRef_>;
  using R = std::remove_cvref_t<RRef_>;

  static_assert(std::is_same_v<typename L::Domain, typename R::Domain>, "Domain mismatch!");
  static_assert(std::is_same_v<typename L::Range, typename R::Range>, "Range mismatch!");

  using Domain = typename L::Domain;
  using Range  = typename L::Range;

  static constexpr bool is_l_rvalue = LRef::is_rvalue;
  static constexpr bool is_r_rvalue = RRef::is_rvalue;

  BinaryExpr() = delete;

  BinaryExpr(const BinaryExpr&)            = default;
  BinaryExpr(BinaryExpr&&)                 = default;
  BinaryExpr& operator=(const BinaryExpr&) = default;
  BinaryExpr& operator=(BinaryExpr&&)      = default;
  ~BinaryExpr()                            = default;

  BinaryExpr(LRef_&& l, RRef_&& r) : l_(std::forward<LRef_>(l)), r_(std::forward<RRef_>(r)) {}

  // Calculate x of this
  Range operator()(const Domain& x) const { return Op::Apply(l_(x), r_(x)); }

  const auto& read_l() const { return l_.read(); }

  const auto& read_r() const { return r_.read(); }

  auto move_l()
  requires(is_l_rvalue)
  {
    return std::move(l_).move();
  }

  decltype(auto) move_l()
  requires(!is_l_rvalue)
  {
    return std::move(l_).move();
  }

  auto move_r()
  requires(is_r_rvalue)
  {
    return std::move(r_).move();
  }

  decltype(auto) move_r()
  requires(!is_r_rvalue)
  {
    return std::move(r_).move();
  }

 private:
  LRef l_;
  RRef r_;
};

struct Plus {
  template <class R>
  requires std::is_floating_point_v<R>
  static R Apply(R l, R r) {
    return l + r;
  }
};

struct Multiply {
  template <class R>
  requires std::is_floating_point_v<R>
  static R Apply(R l, R r) {
    return l * r;
  }
};

class F {
public:
    using Domain = double;
    using Range = double;

    template <class T>
    F(T&& f) : f_(f) {}
    double operator()(double x) const {
        return f_(x);
    }

    friend auto operator+(const F& l, const F& r) {
        return BinaryExpr<const F&, Plus, const F&>(l, r);
    }

    friend auto operator+(F&& l, const F& r) {
        return BinaryExpr<F&&, Plus, const F&>(std::move(l), r);
    }

    friend auto operator+(const F& l, F&& r) {
        return BinaryExpr<const F&, Plus, F&&>(l, std::move(r));
    }

    friend auto operator+(F&& l, F&& r) {
        return BinaryExpr<F&&, Plus, F&&>(std::move(l), std::move(r));
    }

    friend auto operator*(const F& l, const F& r) {
        return BinaryExpr<const F&, Multiply, const F&>(l, r);
    }

    friend auto operator*(F&& l, const F& r) {
        return BinaryExpr<F&&, Multiply, const F&>(std::move(l), r);
    }

    friend auto operator*(const F& l, F&& r) {
        return BinaryExpr<const F&, Multiply, F&&>(l, std::move(r));
    }

    friend auto operator*(F&& l, F&& r) {
        return BinaryExpr<F&&, Multiply, F&&>(std::move(l), std::move(r));
    }

private:
    std::function<double(double)> f_;
};


int main() {
    // case 1
    {
        F f{[](double x) -> double {
            return 1 + x;
        }};
        F g{[](double x) -> double {
            return x * x;
        }};
        auto sum = f + g;

        if (sum(0.5) != 1.75) {
            return 1;
        }
    }

    // case 2
    {
        F f{[](double x) -> double {
            return 1 + x;
        }};

        auto sum = f + F{[](double x) -> double {
            return x * x;
        }};

        if (sum(0.5) != 1.75) {
            return 1;
        }
    }

    // case 3
    {
        auto sum = F{[](double x) -> double {
            return 1 + x;
        }} + F{[](double x) -> double {
            return x * x;
        }};

        if (sum(0.5) != 1.75) {
            return 1;
        }
    }

    // case 4
    {
        // (1 + x) * (1 + 2 * x) + x^2
        auto sum = F{[](double x) -> double {
            return 1 + x;
        }} * F{[](double x) -> double {
            return 1 + 2 * x;
        }} + F{[](double x) -> double {
            return x * x;
        }};

        if (sum(0.5) != 3.25) {
            return 1;
        }
    }
}

実行結果はこちら

課題

ダングリングの問題は対処できました。 では、拡張性についてはどうでしょうか?

例えば、微分したいとしましょう。 数学を学んだ者の端くれとしては関数として実装したい所です。 すると、以下の場合分けが発生します:

  1. BinaryExprが右辺値かどうか
  2. Lが右辺値かどうか
  3. Rが右辺値かどうか
  4. Opの種類

実際は、BinaryExprconst&ならば、2と3の場合分けは無視できますし、対称性を使えばコード量は減るでしょう。 それでも、8種類の部分特殊化とFへの微分の実装が必要になってしまいます。

残念ながら、関数として実装するのは大変です。

メンバ関数として実装するのが良いのかもしれませんが、試せてません。 (Eigenの実装を考えるとそう思えますが)

まとめ

Expression templatesの概要を述べ、欠点の一つであるダングリングする可能性に対処する方法を示しました。 私の方法では、Expression templatesを受け取る関数を実装するのは大変なので、設計段階でどんなメンバ関数が必要かあらかじめ決めておくと良いのかもしれません。

参考文献と謝辞


  1. 『C++テンプレートテクニック 第2版』(著: エピステーメー, 高橋 晶) 

  2. キノコになりたいさんからご回答いただきました。ありがとうございました。