编译期数值

优质
小牛编辑
130浏览
2023-12-01

本节介绍IntegralConstant的重要概念和Hana的元编程范式背后的哲学。让我们从一个奇怪的问题开始。什么是integral_constant?

template<class T, T v>
struct integral_constant{
    static constexpr T value = v;
    typedef T value_type;
    typedef integral_constant type;
    constexpr operator value_type() const noexcept { return value; }
    constexpr value_type operator()() const noexcept { return value; }
};

注意: 如果你觉得这些很新奇,你可能需要看看 std::integral_constant 文档

一个有效的回答是,integral_constant表示数值的类型编码,或者更一般地表示为任何整型对象。比如,我们可以使用模板别名很容易地在该表示中的数值上定义后继函数:

template<typename N>
using succ=integral_constant<int, N::value + 1>;

using one=integral_constant<int,1>;
using two=succ<one>;
using three=succ<two>;
// ...

通常将这种使用integral_constant的方式用于模板元编程的类型实体。我们还会看到另一种integral_constant使用方式是作为一个运行时对象,代表一个整型的constexpr值:

auto one=integral_constant<int,1>{};

这里,虽然one没有标记为constexpr,它所拥有的抽象值(一个constexpr1)在编译期仍然可用,因为该值被编码到one类型中,事实上,即使one不是constexpr,我们也可以用decltype检索它表示的编译期值:

auto one=integral_constant<int,1>{};
constexpr int one_constexpr=decltype(one)::value;

但是为什么我们会想把integral_constant当作对象而不是类型实体呢?为了看是为什么,考虑我们现在如果实现之前同样的后继函数:

template<typename N>
auto succ(N){
    return integral_constant<int,N::value+1>{};
}

auto one=integral_constant<int,1>{};
auto two=succ(one);
auto three=succ(two);

您注意到了什么新东西吗? 区别在于,不是在类型级别使用模板别名实现succ,我们现在使用模板函数在值级别实现它。 此外,我们现在可以使用与普通C++相同的语法执行编译时算术。这种将编译期实体看作对象而不是类型的方式是Hana的表达力的关键。

编译期计算

MPL定义了多个算术运算符以支持使用integral_constant做编译期计算。一个典型的例子是plus运算符,其大致实现如下:

template<typename X,typename Y>
struct plus{
    using type=integral_constant<decltype(X::value+Y::value),X::value+Y::value>;
};

using three=plus<integral_constant<int,1>,integral_constant<int,2>>::type;

通过将integral_constant作为对象而不是类型来看待,从元函数到函数的转换非常简单:

template<typename V,V v,typename U,U u>
constexpr auto operator+(integral_constant<V,v>,integral_constant<U,u>){
    return integral_constant<decltype(v+u>),v+u>{};
}

auto three=integral_constant<int,1>{}+integral_constant<int,2>{};

强调这个操作符不返回正常整数的事实是非常重要。相反,它返回一个值初始化的对象,其类型包含加法的结果。 该对象中包含的唯一有用的信息实际上是在它的类型中,我们正在创建一个对象,因为它允许我们使用这个超赞的值级语法。 事实证明,我们可以通过使用C++14变量模板来简化integral_constant的创建,从而使这种语法更好:

template<int i>
constexpr integral_constant<int,i> int_c{};

auto three=int_c<1>+int_c<2>;

现在我们谈论的是在初始类型层面方法中表现出的增强体验,不是吗? 但还有更多; 我们还可以使用C++14用户定义的字面量使这个过程更简单:

template<char... digits>
constexpr auto operator"" _c(){
    //parse the digits and return an integral_constant
}

auto three=1_c + 3_c;

Hana提供了自己的integral_constants,它定义了算术运算符,就像我们上面显示的一样, Hana还提供了变量模板,可以轻松创建不同类型的integral_constantsint_c,long_c,bool_c等。这允许你省略后面的{}大括号,否则需要值来初始化这些对象。 当然,也提供_c后缀; 它是hana::literals命名空间的一部分,您必须在使用它之前将其导入到命名空间中:

using namesapce hana::literals;

auto three=1_c + 3_c;

这样,你可以在做编译期计算时不必尴尬地与类型级别的特性做斗争,你的同事现在将能够了解发生了什么。

示例:距离公式

为了说明它是多么的好用,让我们实现一个函数在编译期计算一个2-D欧氏距离。作为提醒,2-D平面中的两个点的欧几里得距离由下式给出,先看看用类型计算的样子(使用MPL):

template<typename P1,typename P2>
struct distance{
    using xs=typename mpl::minus<typename P1::x,typename P2::x>::type;
    using ys=typename mpl::minus<typename p1::y,typename P2::y>::type;

    using type=typename sqrt<
        typename mpl::plus<
            typename mpl::multiplies<xs,xs>::type,
            typename mpl::multiplies<ys,ys>::type
        >::type
    >::type;
};

static_assert(mpl::equal_to<
    distance<point<mpl::int_<3>,mpl::int_<5>>,point<<mpl::int_<7>,mpl::int_<2>>>::type,
    mpl::int_<5>
>::value);

嗯...现在,让我们用上面提到的值级方法来实现它:

template<typename P1,typename P2>
constexpr auto distance(P1 p1,P2 p2){
    auto xs=p1.x-p2.x;
    auto ys=p1.y-p2.y;
    return sqrt(xs*xs+ys*ys);
}

BOOST_HANA_CONSTANT_CHECK(distance(point(3_c,5_c),point(7_c,2_c))==5_c);

这个版本看起来简洁多了。然而,这还没完。注意到distance函数看起来和你为计算动态值的欧几里德距离所写的一样吗? 事实上,因为我们在动态值和编译期计算使用了相同的语法,为其编写的通用函数将能同时工作于编译期和运行期!

auto p1=point(3,5); // dynamic values now
auto p2=point(7,2); //
BOOST_HANA_RUNTIME_CHECK(distance(p1,p2)==5); //same function works!

不用改变任何代码,我们可以在运行时使distance函数正确地工作。

编译期分发

现在我们有了编译期计算,下一步需要解决编译期分发问题,元编程时,如果一些条件为真则编译一段代码,否则编译另一段代码是很有用的。就好像是static_if一样。还没搞清楚为什么需要编译期分发?先考察下面的代码(改编自N4461):

template <typename T, typename ...Args>
  std::enable_if_t<std::is_constructible<T, Args...>::value,
std::unique_ptr<T>> make_unique(Args&&... args) {
  return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

template <typename T, typename ...Args>
  std::enable_if_t<!std::is_constructible<T, Args...>::value,
std::unique_ptr<T>> make_unique(Args&&... args) {
  return std::unique_ptr<T>(new T{std::forward<Args>(args)...});
}

以上代码使用构造函数正规的语法形式创建std::unique_ptr。为此,它利用SFINAE实现两个不同的重载。现在,每个看到这些代码的人都不仅会问,为什么不能更简单一点:

template <typename T, typename ...Args>
std::unique_ptr<T> make_unique(Args&&... args) {
  if (std::is_constructible<T, Args...>::value)
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
  else
    return std::unique_ptr<T>(new T{std::forward<Args>(args)...});
}

原因是编译器需要编译if语句的两个分支,而不考虑条件(即使它在编译时是已知的)。但是当T不能从Args...构造时,第二个分支将无法编译,这将导致硬编译错误。 我们真正需要的是找一种方法告诉编译器,当条件为真时,不要编译第二个分支。

为了模拟这一点,Hana提供了一个if_函数,它像一个普通的if语句一样工作,除了它需要一个可以是IntegralConstant的条件,并返回由条件选择的两个值之一(可能有不同的类型)之外。如果条件为真,则返回第一个值,否则返回第二个值。 一个有点空洞的例子如下:

auto one_two_three=hana::if_(hana::true_c,123,"hello");
auto hello=hana::if_(hana::false_c,123,"hello");

注意: hana::true_c 和 hana::false_c 为编译期 IntegralConstant 布尔值。分别表示编译期真值和假值。

one_two_three等于123,hello等于"hello"。从另一个角度看,if_很像?:运算符,除了:分割符两边可以有不同类型外:

//这两条语句都失败了,因为分支有不兼容的类型.
auto one_two_three=hana::true_c ? 123 : "hello";
auto hello=hana::false_c ? 123 : "hello";

好吧,这样的代码看起来非常简洁,但是编译器不支持这个偷懒的办法。那么,如何实现类似if的分支呢?我们决定在分支中使用泛型lambda,借助hana::if_来执行我们想要的分支.以下重写make_unique:

template <typename T, typename ...Args>
std::unique_ptr<T> make_unique(Args&&... args) {
  return hana::if_(std::is_constructible<T, Args...>{},
    [](auto&& ...x) { return std::unique_ptr<T>(new T(std::forward<Args>(x)...)); },
    [](auto&& ...x) { return std::unique_ptr<T>(new T{std::forward<Args>(x)...}); }
  )(std::forward<Args>(args)...);
}

如果条件为真,hana::if_执行第一个泛型lambda分支,为假则执行第二个分支。hana::if_仅简单返回某分支,我们传入了(std::forward<Args>(args)...)参数以便返回的lambda立即执行,这里,预期的泛型lambda将参数x...args...实参执行并返回结果.

这样做(立即传参数)的原因是因为每个分支的主体只能在所有x...类型已知时才被实例化.事实上,由于分支是泛型lambda,在它被调用之前,参数的类型是未知的,编译器必须在检查lambda函数体内类型之前等待x...的类型变为已知.因为当条件不满足(hana::if_忽略了它)时,错误的lambda从不被调用,所以失败的lambda的函数体从不被类型检查,因此不会发生编译错误。

注意: if_的分支是lambda,因此,它们从不同的途径构造了make_unique函数。在这些分支中出现的变量必须被lambdas捕获或作为参数传递给它们,因此它们受到捕获或传递的方式(通过值,引用等)的影响。

由于这种将分支表达为lambda类型然后调用它们的模式是非常常见的,Hana提供了一个eval_if函数,其目的是使编译时分支更容易。 eval_if来自于一个事实,在lambda中,可以接收输入数据作为参数或从上下文中捕获它。 然而,为了模拟语言级if语句,隐含地从封闭范围捕获变量通常更自然. 因此,我们更喜欢这样写:

template <typename T, typename ...Args>
std::unique_ptr<T> make_unique(Args&&... args) {
  return hana::if_(std::is_constructible<T, Args...>{},
    [&] { return std::unique_ptr<T>(new T(std::forward<Args>(args)...)); },
    [&] { return std::unique_ptr<T>(new T{std::forward<Args>(args)...}); }
  );
}

这里,我们捕获了来自闭包范围的args ...变量,这就不需要我们引入新的x...变量并将它们作为参数传递给分支。 然而,还有两个问题。 首先,这样做将不会实现正确的结果,因为hana::if_将最终返回一个lambda,而不是返回调用该lambda的结果。 要解决这个问题,我们可以使用hana::eval_if而不是hana::if_

template <typename T, typename ...Args>
std::unique_ptr<T> make_unique(Args&&... args) {
  return hana::eval_if(std::is_constructible<T, Args...>{},
    [&] { return std::unique_ptr<T>(new T(std::forward<Args>(args)...)); },
    [&] { return std::unique_ptr<T>(new T{std::forward<Args>(args)...}); }
  );
}

这里,我们使用[&]通过引用捕获闭包的args ...,我们不需要接收任何参数. 此外,hana::eval_if假定其参数是可以被调用的分支,它将负责调用由条件选择的分支. 然而,这仍然会导致编译失败,因为lambda的主体不再有任何依赖,因而将对两个分支进行语义分析,即使只有一个将被使用. 这个问题的解决方案是使lambda的主体人为地依赖于某些东西,以防止编译器在lambda被实际使用之前执行语义分析. 为了使这一点成为可能,hana::eval_if将使用标识函数(一个函数无改变地返回其参数)调用所选的分支,如果分支接受这样的参数:

template <typename T, typename ...Args>
std::unique_ptr<T> make_unique(Args&&... args) {
  return hana::eval_if(std::is_constructible<T, Args...>{},
    [&](auto _) { return std::unique_ptr<T>(new T(std::forward<Args>(_(args))...)); },
    [&](auto _) { return std::unique_ptr<T>(new T{std::forward<Args>(_(args))...}); }
  );
}

这里,分支的主体采用约定的称为_的附加参数。这个参数将由hana::eval_if提供给所选择的分支。然后,我们使用_作为我们想要在每个分支的主体内依赖的变量的函数。使用_会发生什么呢?_是一个直接原样返回其参数的函数。但是,编译器在lambda实际被调用之前不可能知道它,因此它不能知道_(args)的类型。这样一来,会阻止编译器执行语义分析,并且不会发生编译错误。另外,由于_(x)保证等于x,我们知道我们实际上没有通过使用这个技巧改变分支的语义。

虽然使用这个技巧可能看起来很麻烦,但当处理分支中的许多变量时,它可能非常有用。此外,不需要用_来包装所有变量; 只有那些包装类型检查必须延迟的表达式中涉及的变量才需要使用它。在Hana中还有一些需要了解的编译时分支,参见hana::eval_if,hana::if_hana::lazy来深入了解它们。

为什么停到这里了

为什么我们应该限制算术运算和分支?当您开始将 IntegralConstants 视为对象时,使用更多通常有用的函数来增加其接口更为明智。 例如,Hana 的 IntegralConstants 定义了一个 times 成员函数,可用于调用函数一定次数,这对于循环展开尤其有用:

__attribute__((noinline)) void f() { }
int main() {
  hana::int_c<10>.times(f);
}

以上代码在编译会展开为调用 10 次 f。相当于如下操作:

f();f();...f();//10 times

注意: 通常要小心手动展开循环或手动执行其他此类优化。在大多数情况下,你的编译器在优化时可能比你更好。

IntegralConstant 的另一个很好的用途是定义更好的运算符来索引异构序列。std::tuple 必须使用std::get 访问,hana::tuple 可以使用用于标准库容器的熟悉的 operator [] 来访问:

auto values=hana::make_tuple(1,'x',3.4f);
char x=values[1_c];

这是怎样让工作变得简单的呢。 基本上,hana::tuple 定义一个使用一个整数常量的运算符 [] 而不是一个通常的整数,类似于:

template<typename N>
constexpr decltype(auto) operator[](N const&){
    return std::get<N::value>(*this);
}

本节,IntegralConstant 部分结束了,本节介绍 了 Hana 的新的元编程方法。如果你喜欢你所看到的。本教程的其余部分应该会感到更加熟悉。