探索 C++20(十二)
七十三、编译时编程
C++ 提供了许多机会来编写在编译时而不是运行时运行的代码。例如,模板提供了一个独特的、功能性的编程环境,尽管它的语法很复杂。在 C++ 20 中,一些新的关键字为你提供了更精确的方法来控制编译时而不是运行时发生的事情。一些编译时编程技术被称为元编程,但是这个术语的定义并不严格,有些可能专门用于类型而不是值的编程。无论如何命名,编译时编程都是整个 C++ 编程的一个有价值的方面。
编译时函数
告诉编译器,你希望它能够在编译时用关键字constexpr或consteval计算一个函数。在函数的返回类型的类型说明符中使用任一关键字(但不要两个都用),就像你在本书中经常看到的那样:
consteval double square(double x) { return x * x; }
constexpr double cube(double x) { return x * x * x; }
对于consteval函数,函数参数必须是编译时常量,编译器总是在编译时调用函数产生一个常量结果。
对于constexpr函数,如果用编译时常量参数调用该函数,编译器在编译时调用该函数并产生一个常量结果。但是您也可以在没有常量参数的情况下调用该函数,编译器会将该函数视为普通的内联函数,并生成适当的运行时代码来产生非常量结果。
假设一个名为value,square(3.0)的变量在编译时变成了9.0,而square(value)是不允许的。在编译时调用cube(3.0)变成27.0,在运行时用value变量计算cube(value)。一个constexpr函数在运行时被求值时是隐式的inline。
一个constexpr或consteval函数有一些限制。大多数限制适用于这两种函数。函数中的返回类型、参数类型和变量类型必须是所谓的文字类型。文字类型是可以在编译时构造和销毁的类型。内置的基本类型、枚举和指针显然适合这个类。对于一个文本类,它必须有一个constexpr析构函数和文本数据成员。被调用的构造器必须是constexpr,尽管这个类可以有其他不是constexpr的构造器。
一个constexpr构造器只能调用其他constexpr构造器,这意味着所有基类构造器都必须是constexpr。如果一个类有一个constexpr析构函数,那么它的所有基类都必须有constexpr析构函数(或者根本没有析构函数使用=delete)。
值得注意的一个区别是析构函数不能用consteval声明。
当然,当你将一个函数声明为constexpr时,编译器需要函数定义,所以你不能将一个constexpr声明放在一个模块接口中,并试图将其定义隐藏在一个单独的模块实现中。定义必须进入模块接口。
编写constexpr函数时有一个挑战。不可能向用户报告错误,例如抛出异常。例如,将所有的rational构造器声明为constexpr似乎是合理的,但这意味着调用reduce(),如果分母为零,这将抛出异常。这意味着构造一个分母为零的constexpr rational对象是非法的,但是编译器不需要发出错误消息。负担落在使用rational的程序员身上,确保 0 永远不会被用作constexpr rational对象的分母。注意,复制 rational 类模板的声明,并确保每一个可以成为 constexpr 的函数都是 constexpr **。**我的拍摄见清单 73-1 。
export module numeric;
import <concepts>;
import <iostream>;
import <numeric>;
import <sstream>;
import <stdexcept>;
export template<class T>
requires std::integral<T>
class rational
{
public:
using value_type = T;
constexpr rational() : rational{0} {}
constexpr rational(value_type num) : numerator_{num}, denominator_{1} {}
constexpr rational(value_type num, value_type den)
: numerator_{num}, denominator_{den}
{
reduce();
}
constexpr value_type numerator() const { return numerator_; }
constexpr value_type denominator() const { return denominator_; }
constexpr rational& operator*=(rational const& rhs) {
numerator_ *= rhs.numerator_;
denominator_ *= rhs.denominator_;
reduce();
return *this;
}
constexpr rational& operator/=(rational const& rhs) {
numerator_ *= rhs.denominator_;
denominator_ *= rhs.numerator_;
reduce();
return *this;
}
... every member function can be constexpr
private:
constexpr void reduce() {
if (denominator_ == 0)
throw std::invalid_argument{"denominator is zero"};
if (denominator_ < 0)
{
denominator_ = -denominator_;
numerator_ = -numerator_;
}
auto div{std::gcd(numerator_, denominator_)};
numerator_ = numerator_ / div;
denominator_ = denominator_ / div;
}
value_type numerator_;
value_type denominator_;
};
export template<class T>
constexpr bool operator==(rational<T> const& lhs, rational<T> const& rhs)
{
return lhs.numerator() == rhs.numerator() and
lhs.denominator() == rhs.denominator();
}
// Every free function except
I/O can be constexpr
export template<class T, class Ch, class Tr>
std::basic_ostream<Ch,Tr>& operator<<(std::basic_ostream<Ch, Tr>& stream, rational<T> const& r)
{
std::basic_ostringstream<Ch,Tr> tmp;
tmp << r.numerator() << '/' << r.denominator();
return stream << tmp.str();
}
Listing 73-1.Adding constexpr Throughout the rational Class Template
编译时变量
constexpr说明符也适用于命名对象。一个constexpr对象是隐式的const。它必须有一个只调用constexpr函数或constexpr构造器的初始化器。
您还可以用constinit关键字初始化一个编译时静态变量。变量必须有静态生存期,即在名称空间范围内或用static关键字声明。编译器总是确保这些变量在main()开始执行之前被初始化。(或者,对于在函数内部定义的static变量,在函数第一次被调用之前。)但是如果一个静态变量的初始值依赖于另一个变量的值,那么先初始化哪个变量是不确定的。通过用constinit声明一个静态变量,编译器在编译时确定这个值,并使用这个固定值来初始化变量,所有依赖于它的变量都会看到这个常量值。constinit与其他const相关关键字的关键区别在于constinit仅适用于初始化。对象不一定是const。清单 73-2 展示了constexpr和constinit对象的一些用法。
import <iostream>;
import rational;
constinit rational<long> r{355, 113};
constinit rational<long> const q{31416, 10000};
int main()
{
constexpr rational<long> p{2};
r /= q;
r *= p;
std::cout << r << '\n';
}
Listing 73-2.Adding constexpr Throughout the rational Class Template
可变长度模板参数列表
您可以定义一个接受任意数量模板参数的模板(称为可变模板)。这种能力并不特别与编译时编程相关,但它是高级的,属于书的末尾。许多编译时编程习惯用法都涉及可变长度模板,因此在这里包含这个主题似乎是合适的。
可变长度模板的许多用途是针对库作者的,但这并不意味着其他人不能加入进来。要声明一个可以接受任意数量参数的模板参数,请在类型模板参数的关键字class或typename后,或者在值模板参数的类型后使用省略号。这样的参数被称为参数包。以下是一些简单的例子:
template<class... Ts> struct List {};
template<int... Ns> struct Numbers {};
用任意数量的模板参数实例化模板:
using int_type = List<int>;
using Char_types = List<char, unsigned char, signed char>;
using One_two_three = Numbers<1, 2, 3>;
您还可以声明一个函数参数包,这样函数就可以接受任意数量的任意类型的参数,如下所示:
template<class... Types>
void list(Types... args);
当您调用函数时,参数包包含每个函数参数的类型。在下面的例子中,编译器隐式地确定Types模板参数是<int, char, std::string>。
list(1, 'x', std::string{"yz"});
sizeof...操作符返回参数包中元素的数量。例如,您可以定义一个Size模板来计算参数包中的参数数量,如下所示:
template<class... Ts>
struct Size { constexpr static std::size_t value = sizeof...(Ts); };
static_assert(Size<int, char, long>::value == 3);
static_assert声明检查编译时布尔表达式,如果条件为假,则导致编译器错误。您可以向static_assert()添加第二个参数来给出一个有用的消息。通常仅仅看到表情就足够了。在编译时能检测到的问题越多越好。
要使用参数包,通常用一个后跟省略号的模式来扩展它。模式可以是参数名、使用参数的类型、使用函数参数包的表达式等等。
清单 73-3 显示了一个print()函数,它接受一个流,后面跟有任意数量的任意类型的参数。它通过扩展参数包来打印每个值。std::forward()函数将一个值转发给一个函数,而不修改或复制它(称为“完美转发”)。对于rest中的每个参数r,编译器将包表达式std::forward<Types>(rest)...扩展为std::forward(r)。通过到处传递右值引用并使用std::forward(),print()函数可以以最小的开销传递对其参数的引用。请注意,这里没有对参数包的大小进行测试。包在编译时被扩展,当包为空时,一个重载函数结束扩展。
import <iostream>;
import <utility>;
// Forward declaration.
template<class... Args>
void print(std::ostream& stream, Args&&...);
// Print the first value in the list, then recursively
// call print() to print the rest of the list.
template<class T, class... Args>
void print_split(std::ostream& stream, T&& head, Args&& ... rest)
{
stream << head << ' ';
print(stream, std::forward<Args>(rest)...);
}
// End recursion when there are no more values to print.
void print_split(std::ostream&)
{}
// Print an arbitrary list of values to a stream.
template<class... Args>
void print(std::ostream& stream, Args&&... args)
{
print_split(stream, std::forward<Args>(args)...);
}
int main()
{
print(std::cout, 42, 'x', "hello", 3.14159, 0, '\n');
}
Listing 73-3.Using a Function Parameter Pack to Print Arbitrary Values
用户定义的文字
编译时编程的一个常见用途是定义自己的文字。标准库将诸如"view"sv这样的文字定义为std::string_view{"view"}的快捷方式。用户定义的文字总是以下划线开头,以避免与标准库中现有或未来的文字冲突。
将文字定义为operator"",后跟文字名称。您可以定义字符、数字或字符串文字。编译器寻找将该值作为参数的函数,或者将组成该值的字符作为模板参数包。清单 73-4 显示了_rev文字,它对整数进行操作以反转位。
consteval unsigned long long operator"" _rev(unsigned long long value)
{
unsigned long long reversed{0};
for (std::size_t i{std::numeric_limits<unsigned long long>::digits}; i > 0; --i)
{
auto bit{ value & 1 };
value >>= 1;
reversed = (reversed << 1) | bit;
}
return reversed;
}
static_assert(0_rev == 0);
static_assert(0x1234567890abcdef_rev == 0xf7b3d5091e6a2c48ULL);
Listing 73-4.Defining a Literal Operator to Reverse Bits in an Integer
用户定义的文本的模板形式尤其难以编写,因为编译器传递的是用户编写的精确形式,这意味着您的操作符必须解释带有撇号的二进制、八进制、十进制和十六进制数字(0b1011'0010_rev、0373_rev、179_rev和0xb3_rev都是相同的值)。让编译器解析数字要容易得多。
作为值的类型
有了constexpr函数,带值的元编程变得更加容易,但是很多元编程涉及类型,这需要完全不同的观点。当使用类型进行元编程时,类型承担值的角色。没有办法定义变量,只有模板参数,所以你设计模板来声明你需要存储类型信息的模板参数。元程序中的“函数”(有时称为元函数)只是另一个模板,所以它的参数是模板参数。
例如,标准库包含元函数is_same(在<type_traits>中定义)。这个模板接受两个模板参数,并产生一个类型作为结果。标准库中的元函数返回带有类成员的结果。如果结果是一个类型,则成员 typedef 被称为type。像is_same这样的谓词的type成员是一个元编程布尔值。如果两个参数类型相同,则结果为std::true_type(也在<type_traits>中定义)。如果参数类型不同,结果是std::false_type。
因为true_type和false_type本身就是元编程类型,所以它们也有类型成员 typedefs。true_type::type的值是true_type;同上false_type。有时元程序必须将元编程值视为实际值。因此,表示值的元编程类型有一个名为value的静态数据成员。如你所料,true_type::value就是true,false_type::value就是false。
你会怎么写 is_same ?你必须声明成员类型定义type到std::true_type或者std::false_type,这取决于模板参数。一个简单的方法是,根据模板参数,从true_type或false_type派生is_same,同时获得便利的value静态数据成员。这是部分特化的直接实现,如清单 73-5 所示。
template<class T, class U>
struct is_same : std::false_type {};
template<class T>
struct is_same<T, T> : std::true_type {};
Listing 73-5.Implementing the is_same Metafunction
让我们写另一个元函数,一个标准库中没有的。这个叫promote。它接受单个模板参数,如果模板参数是bool、short、char,则生成int,否则生成参数本身。换句话说,它实现了 C++ 整数提升规则的简化子集。你会怎么写推广?这次结果是纯类型,所以没有value成员。最简单的方法是最直接的。清单 73-6 显示了一种可能性。
template<class T> struct promote { typedef T type; };
template<> struct promote<bool> { typedef int type; };
template<> struct promote<char> { typedef int type; };
template<> struct promote<signed char> { typedef int type; };
template<> struct promote<unsigned char> { typedef int type; };
template<> struct promote<short> { typedef int type; };
template<> struct promote<unsigned short> { typedef int type; };
Listing 73-6.One Implementation of the promote Metafunction
实现promote的另一种方法是使用模板参数包。假设您有一个元函数is_member,它测试它的第一个参数,以确定它是否出现在由其余参数组成的参数包中。即is_member<int, char>是false_type,而is_member<int, short, int、long>产生true_type。给定is_member,你会如何实现promote?清单 73-7 展示了一种方法,对is_member的结果使用部分特化。
// Primary template when IsMember=std::true_type, that is, T is in the
// list of types to promote to int.
template<class IsMember, class T>
struct get_member {
using type = int;
};
// false means T is not in the list, so leave the type alone.
template<class T>
struct get_member<std::false_type, T>
{
using type = T;
};
template<class T>
struct promote {
using type = get_member<typename is_member<T,
bool, unsigned char, signed char, char, unsigned short, short>::type, T>::type;
};
Listing 73-7.Another Implementation of the promote Metafunction
记住,当命名一个依赖于模板参数的类型时,typename是必需的。元函数的type成员当然有资格作为依赖类型名。这个实现使用部分特化来确定来自is_member的结果。使用is_member实现promote可能看起来更复杂,但是如果类型列表很长,或者可能随着应用程序的发展而增长,那么is_member方法似乎更有吸引力。虽然使用is_member很容易,但是写起来就没那么容易了。还记得清单 73-4 是如何从功能包的头部分离出来的吗?用同样的技术拆分参数包,也就是写一个 helper 类,有一个Head模板参数和一个Rest模板参数包。清单 73-8 展示了实现is_member的一种方式。
template<class Check, class... Args> struct is_member;
// Helper metafunction to separate Args into Head, Rest
template<class Check, class Head, class... Rest>
struct is_member_helper :
std::conditional<std::is_same<Check, Head>::value,
std::true_type,
is_member<Check, Rest...>>::type
{};
// Partial specialization for empty Args
template<class Check, class Head>
struct is_member_helper<Check, Head> : std::is_same<Check, Head>::type {};
/// Test whether Check is the same type as a type in Args.
template<class Check, class... Args>
struct is_member : is_member_helper<Check, Args...> {};
Listing 73-8.Implementing the is_member Metafunction
清单 73-8 没有编写专门针对std::false_type的定制元函数,而是使用了标准元函数std::conditional。尽可能使用标准库通常更好,你可以重写清单 73-7 来使用std::conditional。为了帮助您理解这个重要的元功能,下一节将深入讨论std::conditional。
条件类型
元编程的一个关键方面是在编译时做出决策。为此,您需要一个条件运算符。C++ 在不同的环境中提供了几种不同的方法来实现这一点。例如,在函数内部,可以使用if constexpr。在模板定义中,您可能能够使用约束。标准库在<type_traits>头中提供了两种风格的条件。
要测试一个条件,使用std::conditional<Condition, IfTrue, IfFalse>::type。Condition是一个bool值,IfType和IfFalse是类型。如果Condition为真,则type成员是IfTrue的 typedef,如果Condition为假,则IfFalse的 typedef。
尝试编写自己的 std::conditional 实现。你的标准库可能不同,但不会与我在清单 73-9 中的解决方案有太大的不同。
template<bool Condition, class IfTrue, class IfFalse>
struct conditional
{
using type = IfFalse;
};
template<class IfTrue, class IfFalse>
struct conditional<true, IfTrue, IfFalse>
{
using type = IfTrue;
};
Listing 73-9.One Way to Implement std::conditional
看待std::conditional的另一种方式是将它视为两种类型的数组,由一个bool值索引。由整数索引的类型数组呢?标准库没有这样的模板,但是你可以写一个。使用模板参数包和整数选择器。如果选择器无效,不要定义type成员 typedef。例如,choice<2, int, long, char, float, double>::type会是char,而choice<2, int, long>不会声明一个type成员。试写选择。同样,您可能需要两个相互递归的类。一个类从参数包中去掉第一个模板参数,并递减索引。模板特化终止了递归。将您的解决方案与清单 73-10 中的我的解决方案进行比较。
import <type_traits>;
// forward declaration
template<std::size_t, class...>
struct choice;
// Default: subtract one, drop the head of the list, and recurse.
template<std::size_t N, class T, class... Types>
struct choice_split {
using type = choice<N-1, Types...>::type;
};
// Index 0: pick the first type in the list.
template<class T, class... Ts>
struct choice_split<0, T, Ts...> {
using type = T;
};
// Define type member as the N-th type in Types.
template<std::size_t N, class... Types>
struct choice {
using type = choice_split<N, Types...>::type;
};
// N is out of bounds
template<std::size_t N>
struct choice<N> {};
// Tests
static_assert(std::is_same<int,
typename choice<0, int, long, char>::type>::value, "error in choice<0>");
static_assert(std::is_same<long,
typename choice<1, int, long, char>::type>::value, "error in choice<1>");
static_assert(std::is_same<char,
typename choice<2, int, long, char>::type>::value, "error in choice<2>");
Listing 73-10.Implementing an Integer-Keyed Type Choice
使用新的choice模板从众多选项中选择一个。在一个项目中,我为安全性和性能的不同权衡定义了三种风格的迭代器。快速迭代器尽可能快地工作,没有安全检查。安全迭代器将进行足够的检查以避免未定义的行为。迂腐的迭代器用于调试和检查一切可能的东西,不考虑速度。我可以通过将ITERATOR_TYPE定义为 0、1 或 2 来选择我想要的迭代器样式,例如:
using iterator = choice<ITERATOR_TYPE,
pedantic_iterator, safe_iterator, fast_iterator>::type;
替换失败不是错误(SFINAE)
由 Daveed Vandevoorde 引入的一种编程技术被称为 SFINAE(发音为 ess-finn-ee),因为替换失败不是错误。简而言之,如果编译器试图实例化一个无效的模板函数,编译器不认为这是一个错误,而只是在解决重载时忽略该实例化。在 C++ 20 中引入约束之前,这个概念很普遍,所以您至少需要能够阅读使用 SFINAE 的代码。使用约束的新代码更容易阅读和维护。
举个例子,假设你正在用某种数据编码写数据,比如 ASN.1 BER,XDR,JSON 等等。编码的细节对于本练习并不重要。重要的是,我们希望对所有整数和所有浮点数一视同仁,但对整数和浮点数的处理方式不同。也就是说,我们希望使用模板来减少重复编码的数量,但是我们希望某些类型有不同的实现。我们不能部分特化函数,所以我们必须使用重载。
问题是如何声明三个名为encode的模板函数,这样一个是任何整数的模板函数,另一个是任何浮点类型的模板函数,还有一个是字符串的模板函数。
一种方法是为最大整数类型和最大浮点类型声明重载。编译器会将实际类型转换成更大的类型。这很容易实现,但会产生运行时成本,这在某些环境中可能很大。我们需要一个更好的解决方案。
与std::conditional类似,std::enable_if接受一个布尔值和一个 if-true 类型。与std::conditional不同,它没有 if-false 分支。相反,没有定义类型成员。当编译器查找类型成员时,如果找不到某个模板参数的类型成员,就会触发 SFINAE,从而丢弃该参数的函数签名。
使用enable_if,可以声明重载的encode函数,但是只有当is_integral为真时才启用一个函数,另一个函数用于浮点类型,等等。目标不是禁用encode()函数,而是指导编译器解决重载问题。
<type_traits>头有几个自省特征。每种类型都分为类、枚举、整数、浮点等等。这本书不是关于二进制数据编码的,所以这个例子的内容将把文本写到流中,但是它将用来说明如何使用enable_if。
超载的正常规则仍然适用。也就是说,不同的函数必须有不同的参数。所以对返回类型使用enable_if没有帮助。这一次,enable_if将被用作encode的另一个参数,但是有一个默认值,对调用者隐藏它。(注意,使用enable_if作为函数的主参数是行不通的,因为它破坏了编译器从函数的参数类型推断模板类型的能力。)具体来说,enable_if参数被做成指针类型,用nullptr作为默认值,以确保没有额外的代码来构造或传递这个额外的参数。有了内联,编译器甚至可以优化掉额外的参数,所以没有运行时损失。清单 73-11 展示了解决这个问题的一种方法。
import <iostream>;
import <type_traits>;
template<class T>
void encode(std::ostream& stream, T const& int_value,
typename std::enable_if<std::is_integral<T>::value, T>::type* = nullptr)
{
// All integer types end up here.
stream << "int: " << int_value << '\n';
}
template<class T>
void encode(std::ostream& stream, T const& enum_value,
typename std::enable_if<std::is_enum<T>::value>::type* = nullptr)
{
// All enumerated types end up here.
// Record the underlying integer value.
stream << "enum: " <<
static_cast<typename std::underlying_type<T>::type>(enum_value) << '\n';
}
template<class T>
void encode(std::ostream& stream, T const& float_value,
typename std::enable_if<std::is_floating_point<T>::value>::type* = nullptr)
{
// All floating-point types end up here.
stream << "float: " << float_value << '\n';
}
// enable_if forms cooperate with normal overloading
void encode(std::ostream& stream, std::string const& string_value)
{
stream << "str: " << string_value << '\n';
}
int main()
{
encode(std::cout, 1);
enum class color { red, green, blue };
encode(std::cout, color::blue);
encode(std::cout, 3.0);
encode(std::cout, std::string("string"));
}
Listing 73-11Using enable_if to Direct Overload Resolution
您对 C++ 20 的探索到此结束。下一个也是最后一个探索是一个顶点项目,将你所学的一切整合起来。我希望你能享受你的旅程,并计划更多的旅行来完成你对这门语言的理解和掌握。
七十四、项目 4:计算器
现在是时候通过编写一个简单的文本计算器来应用你在本书中学到的一切了。例如,如果您键入1 + 2,计算器将打印3。这个项目可以像你希望或敢于做的那样复杂。我建议从小处着手,慢慢增加功能:
-
从一个简单的解析器开始,读取数字和操作符。如果您熟悉一个解析器生成器,比如 Bison 或 ANTLR,那就使用它吧。如果你喜欢冒险,试着了解一下 Spirit,这是 Boost 项目的一部分。Spirit 利用 C++ 操作符重载来实现类似 BNF 的语法,以便用 C++ 编写解析器,而不需要额外的工具。如果不想涉及其他工具或库,我推荐一个简单的类似 LISP 的语法,这样就不用把所有时间都花在解析器上了。本书网站上的代码实现了一个简单的递归下降解析器。首先实现基本算术运算符:
+、-、*和/。所有数字都使用 double。被零除的时候做点有帮助的事。 -
添加变量和
=运算符。用一些有用的常量初始化计算器,比如pi。 -
向前的一大步不是在输入表达式时评估每个表达式,而是创建一个解析树。这需要在解析器上做一些工作,更不用说添加解析树类,即表示表达式、变量和值的类。
-
给定变量和解析树,定义函数和调用用户自定义函数是一个较小的步骤。
-
最后,添加将函数保存到文件中的功能,并从文件中加载它们。现在你可以创建有用的函数库。
-
如果你真的雄心勃勃,尝试支持多种类型。使用 pimpl 习语(探索 65 )来定义一个
number类和一个number_impl类。让计算器使用number类,这样它就从number_impl类中解放出来了。为您想要支持的类型实现派生类:integer、double、rational 等等。
正如你所看到的,只要你愿意,这种项目可以继续下去。总会有新的功能添加进来。只是要确保以小增量添加功能。
同样,你的 C++ 专业知识之旅永远不会结束。总会有新的惊喜——在你的下一个项目中,下一次编译器升级就在眼前。在我写这篇文章的时候,标准化委员会已经完成了 C++ 20 的工作,并且已经开始了 C++ 23 的工作。之后将是下一个语言修订周期,下一个,再下一个。
我祝你一路顺风,也希望你享受即将到来的探险。