C++ expression templates lifetime

Avoid CRTP: Use Argument-Dependent Lookup to simplify the library

We want to keep things as simple as possible. The Curiously Recurring Template Pattern (and it's relatives) are powerful tools, but they increase compile-times and are cumbersome when you want to expand what you're doing.

By taking advantage of argument dependent lookup, we can implement operator overloading without having a base class. This greatly simplifies the design of the library. I'll explain more about this in the examples given below

Avoid lifetime issues: store subexpressions by value unless explicitly using std::ref

We want to keep this library simple. An expression is either a constant, a unary operation and an input, or a binary operation and an input. There aren't any class invariants - the inputs can take on any value, and the operation itself is stored based on it's type, so it can only have 1 value.

This means that we can represent expressions as aggregate types, making them trivially constructible, trivially copyable, trivially destructible, and reducing both compiletimes and the size of the resulting binary.

namespace expr // We need to put them in a namespace so we can use ADL
{
    template<class Value>
    class Constant
    {
       public:
        Value value;
    };

    template<class Op, class Input>
    class UnaryOp
    {
       public:
        Op op;
        Input input; 
    };
    template<class Op, class Left, class Right>
    class BinaryOp
    {
       public:
        Op op;
        Left lhs;
        Right rhs; 
    };
}

Simplify operator overloads: use namespace scoping

If we write the operator overloads in a namespace, they'll only be considered when working with types from that namespace. This means we can avoid having a base class, and we can use unconstrained templates.

namespace expr 
{
    template<class A>
    auto operator-(A const& a)
    {
        return UnaryOp<Negate, A>{{}, a}; 
    }
    template<class A, class B>
    auto operator+(A const& a, B const& b) 
    {
        return BinaryOp<Plus, A, B>{{}, a, b}; 
    }
    template<class A, class B>
    auto operator-(A const& a, B const& b) 
    {
        return BinaryOp<Minus, A, B>{{}, a, b}; 
    }
    template<class A, class B>
    auto operator*(A const& a, B const& b) {
        return BinaryOp<Times, A, B>{{}, a, b}; 
    }
}

Simplify evaluation: Operation types know how to evaluate their inputs

This is pretty simple to achieve - basically, any operation is a functor type that knows how to evaluate the inputs. In C++20, this can be achieved with lambdas, but for our purposes we'll just overload the operator().

namespace expr {
    class Negate {
        template<class A>
        constexpr auto operator()(A&& a) const 
            noexcept(noexcept(-a))
            -> decltype(-a)
        {
            return -a; 
        }
    };
    class Plus {
    public:
        template<class A, class B>
        constexpr auto operator()(A&& a, B&& b) const
            noexcept(noexcept(a + b))
            -> decltype(a + b) 
        {
            return a + b; 
        }
    };
    class Minus {
    public:
        template<class A, class B>
        constexpr auto operator()(A&& a, B&& b) const
            noexcept(noexcept(a - b))
            -> decltype(a - b) 
        {
            return a - b; 
        }
    };
    class Times {
    public:
        template<class A, class B>
        constexpr auto operator()(A&& a, B&& b) const
            noexcept(noexcept(a * b))
            -> decltype(a * b) 
        {
            return a * b; 
        }
    };
}

Take advantage of pattern-matching with namespace-scope evaluate

Rather than having it as a member function, we can take advantage of pattern matching and recursion when writing an evaluate function at namespace scope.

namespace expr
{
    // This one is applied to things that aren't constants or expressions
    template<class Thing>
    auto evaluate(Thing const& t) -> Thing const& {
        return t; 
    }
    template<class Value>
    auto evaluate(Constant<Value> const& value) {
        return evaluate(value.value);
    }
    template<class Op, class Input>
    auto evaluate(UnaryOp<Op, Input> const& expr) {
        return expr.op(evaluate(expr.value)); 
    }
    template<class Op, class LHS, class RHS>
    auto evaluate(BinaryOp<Op, LHS, RHS> const& expr) {
        return expr.op(evaluate(expr.lhs), evaluate(expr.rhs)); 
    }
}

Instead of storing reference here:

template<typename E1, typename E2>
class ExpressionSum : public Expression<ExpressionSum<E1,E2>> {
    E1 const& u_; // <------| These are references
    E2 const& v_; // <------|
public:
    ExpressionSum(E1 const& u, E2 const& v) : u_(u), v_(v)
    { }

    // ...
};

These does not cause lifetime extension. The wikipedia article assume the expression template is never stored and only live in the same statement as the expression.

Store them as value:

template<typename E1, typename E2>
class ExpressionSum : public Expression<ExpressionSum<E1,E2>> {
    E1 u_; // <------| Fixed!
    E2 v_; // <------|
public:
    ExpressionSum(E1 const& u, E2 const& v) : u_(u), v_(v)
    { }

    // ...
};

You can also extend std::tuple to piggyback on it's EBO:

template<typename E1, typename E2>
class ExpressionSum : public Expression<ExpressionSum<E1,E2>>, private std::tuple<E1, E2> {
    auto u_() const -> E1 const& { return std::get<0>(*this); }
    auto v_() const -> E2 const& { return std::get<1>(*this); }
public:
    ExpressionSum(E1 const& u, E2 const& v) : std::tuple<E1, E2>(u, v)
    { }

    // ...
};

Tags:

C++