CRTP和完美转发

4
考虑到某些表达式模板机制,标准使用 CRTP,通过值保留其子项:
template <typename T, typename>
struct Expr {};

template <typename T>
struct Cst : Expr<T, Cst<T>> 
{
    Cst(T value) : value(std::move(value)) {}

private:
    T value;
};

template <typename T, typename L, typename R>
struct Add : Expr<T, Add<T, L, R>> 
{
    Add(L l, R r) : l(std::move(l)), r(std::move(r))

private:
    L l; R r;
};

现在,在实现运算符时,必须通过引用传递,因为参数需要向下转换到正确的类型。问题是我发现自己要实现四个 (!) 版本的 operator+

template <typename T, typename L, typename R>
Add<T, L, R> operator+(Expr<T, L>&& l, Expr<T, R>&& r)
{
    return Add<T, L, R>(
        std::move(static_cast<L&>(l)),
        std::move(static_cast<R&>(r)));
}

template <typename T, typename L, typename R>
Add<T, L, R> operator+(const Expr<T, L>& l, Expr<T, R>&& r)
{
    return Add<T, L, R>(
        static_cast<const L&>(l),
        std::move(static_cast<R&>(r)));
}

template <typename T, typename L, typename R>
Add<T, L, R> operator+(Expr<T, L>&& l, const Expr<T, R>& r)
{
    return Add<T, L, R>(
        std::move(static_cast<L&>(l)),
        static_cast<const R&>(r));
}

template <typename T, typename L, typename R>
Add<T, L, R> operator+(const Expr<T, L>& l, const Expr<T, R>& r)
{
    return Add<T, L, R>(
        static_cast<const L&>(l),
        static_cast<const R&>(r));
}

如果目标是最小化不必要的复制,则必须区分可以移动的临时变量和必须被复制的左值,因此有四个重载。

在C++03中,“没有问题”:我们始终使用const引用和复制。在C++11中,我们可以做得更好,这就是本文的目标。

是否有一些技巧可以让我只写一次加法逻辑,或者编写宏是我的最佳选择(因为该逻辑将重复应用于其他运算符)?

我也愿意听取关于如何使用C ++ 11编写表达式模板的其他建议。只需考虑最小化复制的目标,因为存储在终端节点中的值可能是巨大的数字或矩阵(在我的情况下,终端节点可能包含数百兆字节的插值数据,并禁用此类对象的复制 - 对于其他对象,复制是可能的)。


你尝试过仅通过值传递并让编译器优化掉副本吗? - Vaughn Cato
你的对象通常移动起来是否便宜?你的构造函数有点暗示了这一点(否则,通过值传递可能会进行不必要的复制)。在这种情况下,你也可以通过值传递参数给 +(并将它们移动到构造函数调用中)。 - Grizzly
1
@VaughnCato:无法通过值传递,因为对象会被切片!Expr<T, U> 的实际类型是 UU 总是继承 Expr<T, U>):这就是 CRTP 的全部意义。@Grizzly:对象移动廉价,复制昂贵。 - Alexandre C.
我个人使用类似于template<typename Lhs, typename Rhs> expression<operators::plus, Lhs, Rhs> operator+(Lhs&&, Rhs&&);的东西,这样做的好处是如果其中一个操作数不是表达式,则不会构造表达式包装器(在这里是expression类型)。operators::plus是一个常见的多态函数对象。 (operator+像往常一样通过ADL找到,但仍然受到SFINAE的约束,即其操作数中至少有一个是表达式包装器。) - Luc Danton
@LucDanton:我也使用模板模板参数(这里只是为了指出T继承expr<T>的问题,非常优雅地解决方法是反过来:expr<T>继承T,一切都像魔术般顺利运作)。 - Alexandre C.
显示剩余3条评论
2个回答

6

以下是另一种编写表达式模板的方法,允许通过值传递参数:

template <typename T>
struct Expr : T {
  Expr(T value) : T(value) { }
};

template <typename A,typename B>
struct Add {
  A a;
  B b;

  Add(A a,B b) : a(a), b(b) { }
};

template <typename A,typename B>
Expr<Add<A,B> > operator+(Expr<A> a,Expr<B> b)
{
  return Expr<Add<A,B> >(Add<A,B>(a,b));
}

有很多隐含的副本,但我发现编译器在删除它们方面做得非常好。

为了方便使用常量,您可以编写额外的重载:

template <typename A,typename B>
Expr<Add<Constant<A>,B> > operator+(const A& a,Expr<B> b)
{
  return Expr<Add<Constant<A>,B> >(Add<Constant<A>,B>(a,b));
}

template <typename A,typename B>
Expr<Add<A,Constant<B> > > operator+(Expr<A> a,const B& b)
{
  return Expr<Add<A,Constant<B> > >(Add<A,Constant<B> >(a,b));
}

其中Constant是一个类模板,例如:

template <typename T>
struct Constant {
  const T& value;
  Constant(const T& value) : value(value) { }
};

虽然有很多隐式复制,但我发现编译器在消除它们方面做得非常出色。


关于您最后的编辑:我不明白在Expr中,T的单参数构造函数会被称为什么。我只看到一个带有两个参数的Add构造函数。 - Benjamin Bannier
@honk:应该调用自动生成的复制构造函数。 - Vaughn Cato
我认为Add(a,b)应该改为Add<Expr<A>, Expr<B>>(a,b)才能使代码编译通过。 - void-pointer
1
@void-pointer:我已经扩展了答案,以解决如何处理常量(非表达式)的问题。 - Vaughn Cato
@VaughnCato 感谢您的更新。因此,在使工厂函数将“Constant”包装在“Expr”中或为每个运算符创建两个额外的重载之间存在权衡。 - void-pointer
显示剩余3条评论

2

根据评论中的说法,由于对象移动成本较低,我会让operator+按值传递参数,并让编译器在调用点上计算可以避免多少复制。为了避免切片,这意味着operator+需要在派生类型上工作(导致有些过度绑定的operator+)。为了控制这个问题,你可能需要使用std::enable_if,得到以下结果:

template <typename T, typename U>
struct Expr {
    typedef T expr_type;//added for getting T in the enable_if. Could probably also behandled with a custom type trait
};

template <typename L, typename R>
typename std::enable_if<std::is_base_of<Expr<typename L::expr_type, L>, L>::value &&
                        std::is_base_of<Expr<typename L::expr_type, R>, R>::value, 
                        Add<typename L::expr_type, L, R>>::type
operator+(L l, R r) {
    return Add<typename L::expr_type, L, R>(std::move(l), std::move(r));
}

当然,如果经常使用它,将条件封装到一个特性中是一个好主意,可以给你类似这样的东西:
template <typename L, typename R, typename T>
struct AreCompatibleExpressions {
    static constexpr bool value = std::is_base_of<Expr<T, L>, L>::value &&
                                  std::is_base_of<Expr<T, R>, R>::value;
};

template <typename L, typename R>
typename std::enable_if<AreCompatibleExpressions<L, R, typename L::expr_type>::value,
                        Add<typename L::expr_type, L, R>>::type
operator+(L l, R r) {
    return Add<typename L::expr_type, L, R>(std::move(l), std::move(r));
}

为了更加简洁,您可以编写自己的EnableIfCompatibleExpressions,但在我看来这似乎有点过度设计。

顺便提一下:在Add的构造函数中您有一个错误。应该是

Add(L left, R right) : l(std::move(left)), r(std::move(right))

谢谢,这回答了我的问题,但为了避免打错字,这将需要一个宏。我不确定我是喜欢我的四个重载还是enable_if表达式。 - Alexandre C.
@AlexandreC:为什么需要宏?当你经常编写整个条件时,你可能想将其包装到新的类型特征中,但这并不复杂... - Grizzly
operator+,还有operator-*/和其他重载,这些都会涉及到许多std::enable_if表达式。这样的复杂表达式(无论是你的还是我的)需要封装到宏中,以避免手动复制/粘贴(这并不是偏执狂:这里的小错误可能非常难以检测和调试)。 - Alexandre C.
@AlexandreC:正如我所说,您可能希望将其封装在类型特征甚至是自定义的enable_if中。因此我修改了我的答案。仍然没有理由诉诸宏(毕竟宏有它们自己难以调试的方式)。 - Grizzly
对于二进制操作,实际的操作是通过Binary模板的模板模板参数处理的,因此EnableIfCompatible特质实际上是一个好主意。感谢问题中的打字错误,我已经更正了它。 - Alexandre C.

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接