阅读翻译Discovering Modern C++之5.2.3 A `const`-Clean View Example
阅读翻译Discovering Modern C++之5.2.3 A const
-Clean View Example
关于
- 首次发表日期:2024-07-14
- 书籍介绍请看亚马逊链接:https://www.amazon.com/Discovering-Modern-C-Peter-Gottschling-ebook/dp/B09HTJRJ3V
- 使用KIMI和ChatGPT机翻,然后人工润色
- 如有错误,请不吝指出
在这一节中,我们将使用type traits来解决视图(Views)的技术问题。视图是提供对另一个对象不同视角的小对象。一个典型的应用案例是矩阵的转置。提供转置矩阵的一种方式当然是创建一个新的矩阵对象,将相应位置的值交换。这是一个非常昂贵的操作:它需要内存分配和释放,并复制所有交换值后的矩阵数据。视图将更加高效,正如我们将看到的。
5.2.3.1 Writing a Simple View Class(编写一个简单的视图类)
与构建具有新数据的对象不同,视图仅引用现有对象并调整其接口。这对于矩阵转置工作得非常好,因为我们只需要在接口中交换行和列的角色:
在这个例子中,我们假设 Matrix
类提供了一个接受两个参数(行索引和列索引)的 operator()
,并返回对应项 aija_{ij}aij 的引用。我们还假设已经定义了type traits value_type
和 size_type
。这是在这个小例子中我们需要了解的关于所引用矩阵的所有信息(理想情况下,我们会为一个简单的矩阵指定一个concept)。当然,像 MTL4 这样的真实模板库提供了更大的接口。然而,这个小例子足以演示在某些视图中使用元编程的方法。
transposed_view
类的对象可以像常规矩阵一样被对待;例如,它可以被传递给所有期望一个matrix的函数模板。转置是通过调用被引用对象的 operator()
并交换索引时即时实现的。对于每个矩阵对象,我们都可以定义一个像矩阵一样表现的转置视图:
当我们访问 At(i, j)
时,我们将得到 A(j, i)
。我们还定义了一个非 const
访问,这样我们甚至可以更改条目:
At(2, 0)= 4.5;
此操作将 A(0, 2)
设置为 4.5
。定义一个转置视图对象并不会导致特别简洁的程序。为了方便起见,我们添加一个返回转置视图的函数:
现在我们可以在我们的科学软件中优雅地使用 trans
,例如,在矩阵-向量乘积中:
v= trans(A) * q;
在这种情况下,会创建一个临时视图并用于乘积计算。由于大多数编译器会内联视图的 operator()
,使用 trans(A)
进行的计算将和使用 A
一样快,但访问内存的顺序可能会影响性能。
5.2.3.2 Dealing with const
-ness(处理常量性)
到目前为止,我们的视图工作得很好。问题出现在常量矩阵的转置视图中:
const mtl::dense2D <float > B{A};
我们仍然可以创建 B
的转置视图,但无法访问其元素:
cout ≪ "trans(B)(2, 0) = " ≪ trans(B)(2, 0) ≪ '
'; // Error
编译器会告诉我们,它无法从一个 const float
初始化一个 float&
。当我们查看错误的位置时,会发现这是在操作符的非 const
重载中发生的。这引发了一个问题:为什么没有使用 const
重载,因为它返回的是一个常量引用,完全符合我们的需求。
首先,我们想检查 ref
成员是否确实是常量。我们在类定义或函数 trans
中从未使用 const
声明符。运行时类型识别 (RTTI) 提供了帮助。我们添加了头文件 <typeinfo>
并打印类型信息:
这将在使用 g++ 编译器时产生以下输出:
输出在这里不是特别易读。当你使用 Visual Studio 时,使用 typeid
可以看到原始类型名称。不幸的是,在我们看到的所有其他编译器上,RTTI 打印的类型都是名字混淆的。仔细观察时,我们可以看到第二行额外的 K
,这告诉我们视图是使用常量矩阵类型实例化的。尽管如此,我们建议不要浪费时间在混淆的名称上。一个简单(且可移植的!)技巧是通过引发类似这样的错误消息来获得可读的类型名称:
int ta= trans(A);
int tb= trans(B);
更好的方法是使用一个名称解码器。例如,GNU 编译器提供了一个名为 c++filt
的工具,它也适用于 clang++
。默认情况下,它只解码函数名称,我们需要使用 -t
选项,如下所示:trans const | c++filt -t
。然后我们会看到:
现在我们可以清楚地看到,trans(B)
返回一个模板参数为 const dense2D<...>
的 transposed_view
(而不是 dense2D<...>
)。因此,成员 ref
的类型是 const dense2D<...>&
。当我们回过头来看,这现在变得合理。我们将类型为 const dense2D<...>
的对象传递给了函数 trans
,该函数的模板参数类型为 Matrix&
。因此,Matrix
被替换为 const dense2D<...>
,因此返回类型相应地是 transposed_view<const dense2D<...>>
。经过这段短暂的类型内省之旅,我们确定了成员 ref
是一个常量引用。接下来会发生以下情况:
- 当我们调用
trans(B)
时,函数的模板参数被实例化为const dense2D<float>
。 - 因此,返回类型是
transposed_view<const dense2D<float>>
。 - 构造函数参数的类型为
const dense2D<float>&
。 - 同样地,成员
ref
是const dense2D<float>&
。
仍然存在一个问题,即为什么尽管我们引用了一个常量矩阵,却调用了操作符的非 const
版本。答案是,ref
的常量性对选择并不重要,重要的是视图对象本身是否是常量。为了确保视图也是常量,我们可以这样写:
const transposed_view <const mtl::dense2D <float > > Bt{B};
cout ≪ "Bt(2, 0) = " ≪ Bt(2, 0) ≪ '
';
这种方法可行,但相当笨拙。一个粗暴的可能性是去除常量性以使视图能够编译用于常量矩阵。不期望的结果是,常量矩阵的可变视图会允许修改声明为常量的矩阵。这严重违反了我们的原则,以至于我们甚至不会展示这种代码会是什么样子。
**Rule** 将常量性转换视为最后的无奈之举。
以下是处理常量性正确的非常有效的方法。每个 const_cast
都表明设计上存在严重错误。正如Herb Sutter和Andrei Alexandrescu所说,“一旦使用了 const
,就不应再回头。” 我们唯一需要使用 const_cast
的情况是处理const
不正确的第三方软件,比如将只读参数作为可变指针或引用传递的情况。这不是我们的错,我们别无选择。不幸的是,仍然有许多软件包完全忽视了 const
限定符。其中一些软件包太大了,无法快速重写。我们唯一能做的就是在其上增加适当的API,并避免使用原始API。这样可以避免在我们的应用程序中使用 const_cast
,并将难以言说的 const_cast
限制在接口中。Boost::Bindings
就是这样一个很好的例子,它为BLAS、LAPACK和其他类似老式接口的库提供了一个高质量的 const
正确接口(委婉地说)。相反地,只要我们仅使用自己的函数和类,我们就能够通过或多或少的额外工作避免使用任何 const_cast
。
为了正确处理常量矩阵,我们可以为它们实现第二个视图类,并相应地重载 trans
:
使用这个额外的类,我们解决了我们的问题。但我们为此添加了相当多的代码。而且比代码长度更糟糕的是冗余:我们的新类 const_transposed_view
几乎与 transposed_view
相同,只是不包含非 const
的 operator()
。
让我们寻找一个更有成效且不那么冗余的解决方案。为此,在接下来的内容中,我们引入了两个新的元函数。
5.2.3.3 Check for Constancy(检查常量性)
在列表5-1中,我们的视图问题在于无法正确处理所有方法中作为模板参数的常量类型。为了修改常量参数的行为,我们首先需要确定参数是否是常量。为此,标准库提供了类型特性 std::is_const
。这个元函数可以通过部分模板特化非常简单地实现:
常量类型匹配两个定义,但第二个更为具体,因此编译器会选择它。非常量类型只匹配第一个。请注意,我们只考虑最外层的类型:模板参数的常量性不被考虑。例如,view<const matrix>
不被视为常量,因为视图本身不是 const
。
5.2.3.4 Variable Templates(可变模板)
变量模板在元编程中非常有用。除了我们的 is_const
type trait,我们可以定义:
template <typename T>
constexpr bool is_const_v = is_const<T>::value;
这使我们在使用值时不必每次都附加 ::value
。
C++17 为所有基于值的type traits添加了相应的变量模板,后缀为 _v
,正如我们在这里所做的,例如,is_pointer
现在伴随着 is_pointer_v
。当然,is_const_v
在标准库中也是可用的。
5.2.3.5 Compile-Time Branching (编译时分支)
我们视图所需的另一个工具是根据逻辑条件进行类型选择。这项技术由 Krzysztof Czarnecki 和 Ulrich Eisenecker 引入。标准库中的 编译时条件语句 被命名为 conditional
。它可以通过一个相当简单的实现来实现:
当这个模板与一个逻辑表达式和两种类型一起实例化时,只有主模板(在上面)在第一个参数评估为 true
时匹配,并且在类型定义中使用 ThenType
。如果第一个参数评估为 false
,那么特殊化(在下面)更具体,因此使用 ElseType
。像许多巧妙的发明一样,一旦找到它就非常简单。这个元函数是 C++11 的一部分,在头文件 <type_traits>
中。
这个元函数允许我们定义有趣的事情,比如在我们的最大迭代次数大于100时,使用 double
作为临时类型,否则使用 float
:
不用说,max_iter
必须在编译时已知。诚然,这个例子看起来并不特别有用,而且元-if
在小而孤立的代码片段中并不那么重要。相比之下,对于大型通用软件包的开发,它变得非常重要。请注意,比较操作被括号括起来;否则大于号 >
将被解释为模板参数的结束。同样地,在 C++11 或更高版本中,包含右移操作符 >>
的表达式也必须由于同样的原因用括号括起来。
C++14 引入了模板别名,以便我们在引用结果类型时不必键入 typename
和 ::type
:
5.2.3.6 The Final View (最终视图)
现在我们已经拥有了区分所引用的 Matrix
类型常量性的工具。我们可以尝试让mutable的[]
运算符消失,正如我们将在第5.2.6节中对其他函数所做的那样。不幸的是,这种技术只适用于函数本身的模板参数,而不适用于enclosing class的模板参数。
因此,我们同时保留可变的和常量的访问运算符,但根据模板参数的类型选择前者的返回类型:
这种实现根据可变视图与所引用的矩阵的可变性区分了可变视图的返回类型。这确立了所需的行为,如下案例将展示。当引用的矩阵是可变的时,operator()
的返回类型取决于视图对象的常量性:
- 如果视图对象是可变的,则第14行的
operator()
返回一个可变引用(第10行); - 如果视图对象是常量的,则第17行的
operator()
返回一个常量引用。
这与列表5-1中之前的行为相同。如果矩阵引用是常量的,那么总是返回一个常量引用:
- 如果视图对象是可变的,则第14行的
operator()
返回一个常量引用(第9行); - 如果视图对象是常量的,则第17行的
operator()
返回一个常量引用。
总之,我们实现了一个视图类,仅当视图对象和引用的矩阵都是可变时提供写入访问。