C++中的恒定性

大家好!我是大聪明-PLUS

我们继续推出"C++ 深度解析"系列文章。本系列旨在尽可能详细地介绍各种语言特性,其中一些特性相当专业。本文是该系列的第七篇文章;之前的文章列表位于第 10 节末尾。本系列面向具有一定 C++ 编程经验的程序员。本文重点介绍 C++ 中的常量概念。

变量是任何编程语言的基础。如果一个变量的值在初始化后不能改变,那么这类变量就被称为不可变变量、常量变量或简称常量。所有编程语言都以某种形式支持常量变量,并且常量变量发挥着至关重要的作用。这类变量有助于编译器优化代码,提高代码的可读性和可靠性,并允许在编译时检测到更多错误。常量变量有助于解决与多线程访问变量相关的问题。常量变量的使用会影响面向对象编程中的设计决策,而函数式编程则本质上依赖于变量的不可变性。

在 C++ 中,常量概念非常重要。变量的常量类型主要有两种:指向常量的引用和指针、常量成员函数以及常量迭代器。让我们来尝试理解所有这些概念。

1. 常量------声明、初始化、使用规则

1.1 声明常量

1.1.1 基础知识

设 为T内置类型或用户定义类型(类/结构体/联合体/枚举),或使用typedef或声明的别名(别名有限制,参见 2.3 节)。在 C++11 中,关键字和构造也using可用作。T``auto``decltype(expression)

以下语句声明了一个常量变量,或者简称为常量,其类型为T

复制代码
const` `T` `x`; `

该关键词const指的是所谓的简历限定词,其中也包括关键词volatile

常量通常在声明时显式初始化,详情请参见第 1.2 节。

限定符const可以出现在类型名称之前或之后。声明

复制代码
const` `T` `x`;`

将等同于声明

复制代码
T` `const` `x`;`

第一种形式更为常见,但一些专家(例如参见[VJG])认为第二种形式更可取,并提出了令人信服的论据。这种形式也常用于编译器消息中。本文将采用传统形式,即限定符const位于类型名称之前。

你可以在一条语句中声明多个变量;这些变量之间用逗号分隔。在这种情况下,常量性将应用于所有变量。声明

复制代码
const` `T` `x`, `y`;`

将与公告内容相同。

复制代码
const` `T` `x`;
`const` `T` `y`;`

1.1.2. 数组、指针、引用

声明常量时,可以使用数组、指针或引用说明符。

如果N在编译时对整数表达式求值,且其值大于零,则可以声明一个常量数组:

复制代码
const` `T` `ax`[`N`]; `

指针说明符有两种使用方式:

复制代码
const` `T` `*pcx`;  
`T` `*const` `cpx`;  `

指向常量的指针本身并不是常量;它可以被改变,但指针指向的变量的值不能被改变。

复制代码
const` `int` `x` `=` `42`;
`const` `int` `*pcx` `=` `&x`;
`*pcx` `=` `0`; 
`pcx` `=` `nullptr`; `

常量指针是一个自身为常量的指针,但它可以指向一个非常量。

复制代码
int` `x` `=` `42`;
`int` `*const` `cpx` `=` `&x`;
`*cpx` `=` `0`; 
`cpx` `=` `nullptr`; `

这两种恒常性变体可以结合起来。

复制代码
const` `T` `*const` `cpcx`; `

本案中的限制条件也是合并计算的。

可以使用指针别名来声明常量指针:

复制代码
using` `PT` `=` `T*`; 
`const` `PT` `cpx`;  `

引用说明符只能以一种方式使用:

复制代码
const` `T` `&rcx`;  `

您无法更改常量引用所引用的变量的值。

复制代码
const` `int` `x` `=` `42`;
`const` `int` `&rcx` `=` `x`;
`rcx` `=` `0`; `

引用本身是"类似常量"的对象;它们必须始终显式初始化,并且不能改变它们所引用的对象,但它们并非严格意义上的常量。遗憾的是,我们经常会遇到一个术语上的误区:指向常量的引用被称为常量引用。从形式上讲,引用不能是常量,但它可以指向常量对象或非常量对象。对于指针,我们必须明确区分这些类型的常量,但对于引用而言,情况并非如此。

1.1.3. 动态对象

您还可以创建动态常量对象:

复制代码
const` `T` `*pcx` `=` `new` `const` `T`; `

new在这种情况下,该运算符返回指向常量的指针。

动态常量对象会像往常一样被删除,运算符delete可以delete[]应用于指向常量的指针。

的确,动态常量对象可以被认为是相当特殊的;很难给出任何有用的例子。

1.2. 常量初始化

常量必须初始化;否则会导致编译器错误。但如果联合体包含非常量成员,则联合体的 const 成员可以例外。C++ 中的初始化语法非常多样,但我们在本系列的前一篇文章中已经详细介绍过,因此这里只做简要概述。

可以使用四种语法结构来初始化变量:

  1. 象征=;
  2. 括号;
  3. 牙套;
  4. =符号和花括号的组合。

以下是一些例子:

复制代码
const` `int` `x1` `=` `5`;
`const` `int` `x2`(`5`);
`const` `int` `x3`{ `5` };
`const` `int` `x4` `=` { `5` };`

根据变量类型和声明上下文的不同,其中一些选项可能无效。

初始化通常与声明发生在同一语句中,但类成员的初始化除外,类成员的声明和初始化有时可能在不同的语句中进行。以下是一些示例:

复制代码
const` `int` `x` `=` `42`; 

`class` `X`
{
    `static` `const` `double` `Factor`; 
    `const` `int` `m_Id`;             
`public`:
    `X`(`int` `id`);
`// ...`
};

`X::X`(`int` `id`): `m_Id`(`id`) {}      
`const` `double` `X::Factor` `=` `0.04`; `

声明变量时未显式初始化并不总是意味着该变量未初始化。如果声明的变量类型非平凡,那么如果存在默认构造函数(此类构造函数可由编译器生成),则会自动调用该构造函数并初始化变量。如果不存在此类构造函数,则会发生错误。例如:

复制代码
const` `std::string` `emptyStr`; `

平凡类型的常量(即内置类型、指针、枚举和结构,其成员均为平凡类型)必须始终显式初始化。

最后需要注意的是,初始化并非赋值,尽管在某些情况下会使用 `*` 符号=。值只能赋给先前创建的变量,而初始化发生在创建变量时。最重要的是,常量不允许赋值。

1.3 编译时常量

常量的初始化表达式可以分为两类:编译时求值的表达式和运行时求值的表达式。以下是一个运行时初始化的示例:

复制代码
int` `CalcMaxLength`();
`// ...`
`const` `int` `MaxLength` `=` `CalcMaxLength`();`

但如果常量的值在编译时确定,它就获得了另一种身份------编译时常量。使用这类常量可以增强编译器的代码优化能力。编译时整数常量还有其他用途------它们可以用于确定数组大小、作为非类型模板参数、确定枚举元素的值或确定对齐方式。以下是一些示例:

复制代码
const` `int` `N` `=` `32`;
`int` `a`[`N`];
`std::array<int`, `N>` `aa`;
`enum` `Flags` { `Yoo` `=` `N` };
`alignas`(`N`) `char` `buffer`[`1024`];`

C++11 进一步发展了编译时常量的概念,引入了一个新的关键字 `int` constexpr。现在,编译时常量更准确地声明为 `int` constexpr,而不是 `int` const。在 C++11 中,`int` 关键字的用途不仅限于变量声明;更多详情请参见第 8 节。

1.4 类型推断和恒定性

C++11 引入了避免显式声明变量类型,而是从其初始化表达式的类型推断变量类型的功能。这是通过关键字 `type` 实现的auto。(该关键字出现在早期的 C 语言版本中,但其使用被证明是多余的,因此 C++11 采用了新的用法。)

在 C 语言中,auto可以使用限定符、引用说明符和指针说明符const。数组、非静态类成员或静态类成员必须经过初始化才能声明。如果在单个语句中声明多个变量,则所有变量的推断底层类型必须相同。

这种用法auto有一些不太明显的特点。当进行类型推断时,变量的引用和常量属性会从初始化表达式中移除。也就是说,在声明中......

复制代码
auto` `x` `=` `expression`;`

变量类型x永远不会是常量或引用类型。如果表达式expression包含引用类型,则会创建引用所指向实例的副本。限定符const和引用说明符必须显式指定。

复制代码
const` `auto` `c` `=` `expression`;
`const` `auto` `&rc` `=` `expression`;`

在使用容器时,需要考虑这个特性,因为对于迭代器来说,重载运算符*会返回对容器元素的引用。标准的容器成员函数,例如索引器、`init` at()、 `returns` 和 `returns` front(),也会返回对元素的引用back()

C++11 引入了 `T` 构造decltype(expression)。它本质上是表达式类型的别名expression,同时保持了类型恒定性。以下是一个示例:

复制代码
const` `int` `cx` `=` `42`;
`decltype`(`cx`) `cx2` `=` `125`; `

这种结构主要用于模板。

1.5. 使用常量的基本规则

1.5.1 恒常性的意义

常量的关键特性在于,一旦初始化,其值就不能更改。禁止对常量执行任何可能改变其位图的操作。此外,也禁止获取指向常量的引用或指针,因为这些引用或指针可能被用于修改常量。以下是更详细的解释。

  1. 常量禁止修改操作。对于内置类型和枚举,这意味着禁止赋值,以及(如果支持)禁止递增和递减操作。对于用户自定义类型(类/结构体/联合体),这意味着禁止调用非常量成员函数(参见第 3 节)。
  2. 指向非常量对象的引用不能用常量或指向常量的引用来初始化。指向非常量对象的指针不能用指向常量的指针来初始化。指向非常量对象的指针不能被赋予指向常量的指针的值。
  3. 当对应的参数是指向非常量对象的引用或指针时,不允许使用常量作为函数参数。
  4. 如果用户定义的类型常量具有可访问的成员,则这些成员将被视为常量,并且规则 1-4 也适用于它们。

我们来看一些例子:

复制代码
void` `Foo`(`int` `&rx`);
`void` `Foo`(`int` `*px`);
`// ...`
`const` `int` `x` `=` `42`;
`x` `=` `0`;  
`x` `+=` `1`; 
`++x`;    
`int` `&rx` `=` `x`;  
`int` `*px` `=` `&x`; 
`Foo`(`x`);  
`Foo`(`&x`); 
`const` `int` `&rсx` `=` `x`;  
`const` `int` `*pсx` `=` `&x`; `

1.5.2. 常量的引用和指针

非常量变量的引用不能用常量或常量引用进行初始化,指向非常量变量的指针也不能用指向常量的指针进行初始化。但是,常量变量的引用可以用非常量变量或非常量变量的引用进行初始化,指向常量的指针可以用指向非常量变量的指针进行初始化。

复制代码
int` `x` `=` `42`;
`const` `int` `&rcx` `=` `x`;  
`const` `int` `*pcx` `=` `&x`; `

所有与常量相关的限制都适用于通过这些引用或指针访问的此类变量。因此,我们可以为非常量变量提供一个常量接口。换句话说,在特定上下文中,我们可以临时地将任何变量"转换"为常量。

1.5.3. 引用链接和指针

上一节所述内容意味着,指向非常量类型的引用和指针会隐式转换为指向常量类型的引用和指针。因此,我们可以在任何需要指向常量类型的引用和指针的地方使用指向非常量类型的引用和指针。然而,反向转换(即移除常量属性)只能显式执行;这就是 `\const` 运算符的作用const_cast<>。以下是一个示例:

复制代码
const` `int` `*pcx`;
`int` `*px` `=` `const_cast<int*>`(`pcx`);`

理论上,这样的鬼魂可能会被用来做一些非常邪恶的事情。举个例子:

复制代码
const` `int` `cx` `=` `2`;
`const` `int` `&crx` `=` `cx`;
`int` `&rx` `=` `const_cast<int&>`(`crx`);
`rx` `=` `3`;`

这当然是一种糟糕的风格(你怎么会想到这种事?!),而且常量可能位于只读存储器中,在这种情况下,应用程序将会崩溃。

然而,有时需要移除常量性,例如在使用外部 API 时。在这种情况下,务必仔细考虑可能产生的后果。第 4.2 节提供了一个正确移除常量性的示例。

2. 常量类型

常量变量的类型称为常量类型。

2.1. 常量类型的别名

你可以为常量类型声明别名:

复制代码
using` `CINT` `=` `const` `int`;`

您也可以使用旧版本typedef

复制代码
typedef` `const` `int` `CINT`;`

随后宣布

复制代码
CINT` `cх`;`

将等同于声明

复制代码
const` `int` `cx`;`

公告

复制代码
CINT` `*pcх`;`

将等同于声明

复制代码
const` `int` `*pcx`;`

请注意,指向常量的引用和指针本身并不是常量类型。

2.2. const void

类型可以是常量void

复制代码
using` `CV` `=` `const` `void`; 
`const` `void*` `pcv` `=` `nullptr`; `

这种类型本身并没有什么特别的含义,但这种假设在编写模板时可能很有用。

2.3 不能是常量的类型

有些类型不能声明为 const。const 类型、引用、数组和函数不能声明为 const。

不支持双重常量;如果存在两个限定符const,则忽略第二个。声明

复制代码
const` `T` `const` `x`;`

将等同于声明

复制代码
const` `T` `x`;`

在这种情况下,编译器可能会发出警告。

你可以尝试这样声明:

复制代码
using` `CINT` `=` `const` `int`;
`const` `CINT` `ccх` `=` `8`;`

在这种情况下const,它将被忽略,编译器可能会发出警告。

引用本质上是类似常量的对象;它们必须始终显式初始化,并且不能更改它们所指向的对象。(一种常见的观点是,引用本质上是"底层"的常量指针。)但是引用本身不能声明为 const,因为这实际上会造成双重 const。您可以尝试像这样声明它们:

复制代码
int` `&const` `crx`;`

或者像这样

复制代码
using` `RINT` `=` `int&`;
`const` `RINT` `crx`;`

在这种情况下const,它将被忽略,编译器可能会发出警告或错误。

数组本身不能是常量,只有数组元素才能是常量。

复制代码
const` `int` `x`[`4`];`

声明一个常量数组。

你可以尝试这样声明:

复制代码
using` `I4` `=` `int`[`4`];
`const` `I4` `x`;`

在这种情况下,结果将与

复制代码
const` `int` `x`[`4`]; `

Decay(数组到指针的衰减)将常量数组转换为指向常量的指针,而该常量本身不是 const。

函数本身不能是常量,只有返回值可以是常量。

复制代码
const` `int` `Foo`();`

声明一个返回类型为 的常量的函数int

你可以尝试这样声明:

复制代码
using` `F` `=` `void`(`int`);
`const` `F` `cf`;`

在这种情况下const,它将被忽略,编译器可能会发出警告或错误。

你可以尝试像这样声明一个指向常量函数的指针:

复制代码
void`(`const` `*pcf`)(`int`);`

在这种情况下,我们会收到错误提示。

但是你可以声明一个指向函数的常量指针:

复制代码
void`(`*const` `cpf`)(`int`);`

2.4 在模板中使用常量类型

常量类型可以用作模板参数,也可用于特化类模板,但某些特定模板可能存在限制。例如,常量类型可用于实例化类模板std::array<>,但不能用于实例化其他标准容器。以下是 MSVS 消息的示例:

The C++ Standard forbids containers of const elements because allocator<const T> is ill-formed.

如果一个类模板可以用常量类型实例化,那么就可以使用常量类型对该模板进行部分特化。例如:

复制代码
`
`template` `<typename` `T>`
`struct` `U`
{
    `const` `char*` `Tag`() `const` { `return` `"primary"`; }
};


`template` `<typename` `T>`
`struct` `U<const` `T>`
{
    `const` `char*` `Tag`() `const` { `return` `"const"`; }
};

`U<int>` `u1`;
`U<const` `int>` `u2`;

`std::cout` `<<` `u1`.`Tag`() `<<` `' '` `<<` `u2`.`Tag`() `<<` `'\n'`;`

结论:primary const

对于可以使用 const 类型作为模板参数的编程模板,标准库(头文件)提供了几个类型属性,<type_traits>允许您确定和更改模板中使用的类型的常量性:std::is_const<>,,std::add_const<>std::remove_const<>

3. 常非静态成员函数

3.1 公告和基本要求

非静态成员函数可以按如下方式声明为 const:

复制代码
class` `X`
{
`// ...`
    `int` `GetWeight`() `const`;
};`

在 const 成员函数中,调用该函数的实例(由指针指向this)被视为 const。因此,const 成员函数不得修改调用它的实例的位图,也不得返回可用于修改该位图的引用或指针。

因此,在定义常量成员函数时,必须满足以下要求:

  1. 不得修改其自身类的成员。如果用户定义类型的类成员按值聚合,则其成员也不能修改(有关修改的含义的更多信息,请参阅第 1.5 节)。
  2. 不能调用自身类的非常量成员函数。如果用户自定义类型的类成员按值聚合,则也不能对其调用非常量成员。
  3. 成员函数返回的值不能是指向该类成员的指针或引用,也不能是指向该类成员的指针或引用。如果用户自定义类型的类成员是按值聚合的,则成员函数返回的值也不能是指向该类成员的指针或引用,也不能是指向该类成员的指针或引用。

我们来看一个例子:

复制代码
class` `X`
{
    `int` `m_Weight`;
`// ...`
    `const` `int` `&` `GetWeight`() `const` { `return` `m_Weight`; } 
    `void` `DoubleWeight`() `const` { `m_Weight` `*=` `2`; }    
    `int` `&` `GetWeightRef`() `const` { `return` `m_Weight`; } 
    `void` `GetWeightPtr`(`int*&` `pw`) `const` { `pw` `=` `&m_Weight`; } 
};`

如果将非静态类成员声明为mutable,则可以在常量函数中对其进行修改。

对于 const 类的实例,只能调用 const 成员函数;而对于非 const 类的实例,可以调用任何成员函数。因此,通过将函数声明为 const,我们扩展了它的使用范围。

3.2. 成员函数的 const 版本和非常量版本

一个类可以有两个同名且参数相同的成员函数,但它们的常量性(const)和返回值通常不同。因此,它们是重载的。以下是这两个索引器变体的声明方式std::vector<>

复制代码
value_type` `&operator`[](`size_type` `ind`);
`const` `value_type` `&` `operator`[](`size_type` `ind`) `const`;`

在重载解析过程中,对于 const 类实例,将选择成员函数的 const 版本;对于 non-const 实例,将选择 non-const 版本。如果调用发生在另一个 const 成员函数内部,则也选择 const 版本;如果调用发生在 non-const 成员函数内部,则选择 non-const 版本。

3.3. 销毁器

现在我们来考虑析构函数的常量性问题。如果我们尝试将析构函数声明为 const,编译器会报错。原因显而易见。对于任何非平凡类型的对象,无论其是否为 const,析构函数都必须在其生命周期结束时被调用。因此,析构函数对于 const 对象和非 const 对象必须表现得完全相同。一旦析构函数被调用,该对象就无法再被使用;尝试使用它会导致未定义行为。因此,析构函数是否修改对象的位图并不重要------这些位图都无法被使用。所以,考虑析构函数的常量性是没有意义的。

还要注意的是,使用常量指针运算符删除动态对象delete以及使用常量迭代器从容器中删除对象都是完全合法的;不会违反恒定性原则(参见第 4.1 节)。

3.4 物理恒常性和逻辑恒常性

物理恒常性指的是第 3.1 节中概述的形式规则。逻辑恒常性指的是不太正式的标准,更多地植根于常识。

让我们考虑两个例子,在违反物理恒定性的情况下,何时最好使构件函数保持不变。

考虑一个用于访问某个类成员的成员函数。如果该类成员按照惰性求值方式初始化,即在首次访问时初始化,那么该成员函数在形式上不能被视为常量,但可以假定它没有违反逻辑常量性。这样的成员函数可以声明为常量,相应的类成员也可以声明为常量mutable

另一个例子是互斥锁的使用。互斥锁操作(锁定和解锁)是非 const 的,因此,如果互斥锁是类成员,那么使用它的成员函数在形式上不能是 const 的。然而,互斥锁操作纯粹是技术性的,底层类成员可能不会改变。在这种情况下,也可以假设不存在逻辑常量性问题,因此可以将成员函数声明为 const,并将互斥锁声明为mutable

现在让我们考虑一些例子,在保持物理恒定性的同时,将成员函数声明为非常量函数是合适的。

假设我们有一个类,它的成员包含指向另一个对象的指针或引用。通过这样的指针或引用调用非常量成员函数不会改变指针本身的位图,因此可以在常量成员函数中执行此操作。类似地,常量成员函数可以返回指向通过该指针或引用访问的非常量对象的指针或引用。在这种情况下,我们讨论的是物理常量和逻辑常量被违反的情况,这样的成员函数可以声明为非常量。然而,如果子对象图很深,追踪远分支中的非常量性可能是一项具有挑战性的任务。

逻辑常量的一种解释可能是这样的:如果容器实例是常量(const),我们能否修改容器中的元素?编写一个允许这种修改的容器很容易。但标准库中的容器禁止此类操作:如果容器实例是常量,则禁止修改其元素。用于访问容器元素的成员函数(例如 `getElementById`、`getElementById`、`getElementById`、`getElementById` 等at()operator[]()front()两个back(重载data()版本:常量(const)和非常量(参见 3.2 节)。常量版本返回指向常量的引用或指针。(operator[]()映射(`map` std::*map<>)是一个例外,但这属于特殊情况。)返回迭代器的成员函数(例如begin()`getElementById`、end()`getElementById` 等)的工作方式类似;它们的常量版本返回常量迭代器(参见 4.1 节)。因此,如果容器实例是常量,则用于访问元素的成员函数返回指向常量或常量迭代器的引用,并且不可能对元素进行"合法"修改。

现在我们来看一下智能指针。重载运算符 `include`->*`include` 都是 `const` 成员函数,但它们分别返回一个普通指针和一个引用。这意味着我们可以使用 `const` 智能指针来修改控制它的对象。为了防止修改该对象,智能指针必须使用 `const` 类型实例化。这种行为与普通指针完全相同。

3.5. 恒定性和多线程

常量成员函数非常适合安全多线程。如果成员函数不修改任何数据,则可以从不同的线程安全地调用它们。

4. 迭代器

迭代器是STL的关键组成部分,它就像"粘合剂"一样,将STL的另外两个部分------容器和算法------连接起来。迭代器是类似指针的对象;它们实现了内置的指针接口,以便在保持恒定性的同时访问容器元素。

4.1 常量迭代器和非常量迭代器

标准容器提供两种类型的迭代器:常规迭代器,可用于修改容器中的对象;以及常量迭代器,它是只读的。对于常量迭代器,`__const` 运算符*返回对常量的引用,而 `__const` 运算符->返回指向常量的指针。迭代器是嵌套类型,它们的名称分别iterator对应常规迭代器和const_iterator常量迭代器。完整的名称比较冗长,因此通常使用 `__const` 关键字来声明它们auto

在 C++98 中,使用 const 迭代器相当不便------const 迭代器只能通过 const 容器实例获得。在 C++11 中,容器开始支持成员函数cbegin()cend()及其反向对应函数),这些函数会为非 const 容器实例返回 const 迭代器。这使得容器本身的常量性与其元素的常量性能够更清晰地分离,从而编写出更清晰、更健壮的代码。此外,那些不修改容器元素但修改容器本身的操作(元素插入、删除)现在接受 const 迭代器,这更好地符合常量性的原则。此类操作由成员函数(insert()、)erase()和其他一些函数实现。请注意,erase()从容器中删除元素会调用其析构函数,但此操作适用于 const 对象(参见 3.3 节)。以下是一个函数模板示例,用于删除容器中所有满足给定条件的元素:

复制代码
template<class` `C`, `class` `P>`
`int` `EraseIf`(`C` `&cont`, `P` `pred`)
{
    `int` `ret` `=` `0`;
    `for` (`auto` `itr` `=` `cont`.`cbegin`(); `itr` `!=` `cont`.`cend`();)
    {
        `if` (`pred`(`*itr`))
        {
            `itr` `=` `cont`.`erase`(`itr`);
            `++ret`;
        }
        `else`
        {
            `++itr`;
        }
    }
    `return` `ret`; 
}`

标准库还包含自由(非成员)函数模板cbegin()cend()等等,这些模板不仅可以与标准容器一起使用,还可以与"类似容器"的类型(如数组)一起使用。

非常量迭代器和对应的常量迭代器之间存在隐式转换,这意味着我们可以在任何需要常量迭代器的地方使用非常量迭代器。这与指针的相应行为完全类似(参见 1.5.3 节),如果不是这样,将会非常不方便。然而,与指针不同的是,不存在简单且可移植的反向转换------即从常量迭代器到对应的非常量迭代器。

现在我们来探讨一下"常量迭代器"这个术语。它的情况与"常量引用"(参见 1.1.2 节)类似。迭代器本身几乎从来都不是常量,但它可以访问常量对象和非常量对象。Stefan Dewhurst [Dewhurst] 讨论过这个问题,但他并没有给出他认为正确的术语。因此,我们将"常量迭代器"这个术语视为在精确性和易用性之间取得的折衷方案。

4.2 部分容器的特点

让我们来探讨一下容器std::set<>及其std::unodered_set<>类似物multi。在这些容器中,存储的对象既是键(用于关联容器中的比较或无序容器中的哈希函数计算),也是值(包含一些数据,可能与键不同)。容器中存储对象的键不可更改,但容器无法区分对存储对象的更改是影响键的还是其他更改(封装!),因此被迫禁止任何更改,以防万一。因此,容器将所有迭代器视为本质上的常量,这意味着它们不允许修改容器中存储的对象。然而,在这样的对象中,通常可以区分真正不可更改的键部分和未用于键操作且可以直接在容器中安全修改的数据。容器并不知道这种区别,但程序员知道,并且可以安排对非键数据的修改。以下是一个示例:

复制代码
class` `Item`
{
    `const` `int` `m_Id`;   
    `int`       `m_Data`; 
`public`:
    `explicit` `Item`(`int` `id`, `int` `data` `=` `0`)
        : `m_Id`(`id`), `m_Data`(`data`) {}
    `bool` `operator<`(`const` `Item&` `itm`) `const`
        { `return` `m_Id` `<` `itm`.`m_Id`; }
    `int` `Id`() `const` { `return` `m_Id`; }
    `int` `Data`() `const` { `return` `m_Data`; }
    `void` `SetData`(`int` `data`) { `m_Data` `=` `data`; }
};

`using` `ItemSet` `=` `std::set<Item>`;

`bool` `SetData`(`ItemSet&` `items`, `int` `id`, `int` `data`)
{
    `bool` `ret` `=` `false`;
    `ItemSet::iterator` `itr` `=` `items`.`find`(`Item`(`id`));
    `if` (`itr` `!=` `items`.`end`())
    {
        `const` `Item&` `citm` `=` `*itr`;
        `Item&` `itm` `=` `const_cast<Item&>`(`citm`);
        `itm`.`SetData`(`data`);
        `ret` `=` `true`;
    }
    `return` `ret`;
}`

迭代器运算*符返回的是一个常量引用,即使迭代器本身是非 const 的,而这正是我们正在讨论的此类容器的特性。我们移除了这个引用的 const 属性,因此可以使用这个非 const 引用SetData()来修改数据。(关于移除 const 属性的更多信息,请参见 1.5.3 节。)成员m_Data不会用于类实例的比较Item,因此此操作是合法的。请注意此示例的以下特性:该类的Item设计使得即使通过对非 const 引用的引用,键也无法被修改。建议在处理此类容器时使用此技术。

5. 函数参数为常量,函数返回值为常量

5.1 函数的常数参数

声明函数时,参数的常量性会被忽略。

复制代码
void` `Foo`(`const` `int` `x`);
`void` `Foo`(`int` `x`);`

这不是过载,而是同一件事。

定义函数时,参数恒定性是有意义的------这些参数不能被修改。修改参数本身不是错误,但却是糟糕的编码风格的表现。

5.2. 函数的常量返回值

从现在开始,我们将右值理解为具有值但没有被命名变量表示的表达式。我们将左值理解为被命名的变量。

函数的返回类型可以是常量。如果是内置类型,这样做意义不大,但对于用户自定义类型(枚举类型除外)则很有用。原因是内置类型的右值不可修改,而用户自定义类型(枚举类型除外)的右值可以修改。更准确地说,可以对用户自定义类型调用非常量成员函数,因此,我们可以修改函数调用结果的右值。以下是一些示例:

复制代码
class` `X`
{
`// ...`
    `X&` `operator=`(`int` `x`);
};

`int` `G`();
`X` `F`();

`G`() `=` `42`; 
`F`() `=` `42`; `

这种行为可能并不理想。例如,在重载运算符时,一些内置运算符版本无法实现的"奇怪"表达式在语法上可能是正确的。在这种情况下,返回类型应该声明为 const,并且禁止将其修改为右值。以下是一个示例。

复制代码
class` `Int`
{
    `int` `m_Value`;
`public`:
    `Int`(`int` `val`) : `m_Value`(`val`) {}
`// ...`
    `Int` `operator++`(`int`) `// post-increment`
    {
        `auto` `t` `=` `m_Value`;
        `++m_Value`;
        `return` `Int`(`t`);
    }
};`

我们来看一下代码:

复制代码
Int` `r` `=` `0`;
`r++++`;`

这段代码可以编译,但对于该运算符的内置版本无效,而且语义也非常奇怪。

为防止出现这种"异常情况",必须将运算符的返回值声明为常量:

复制代码
const` `Int` `operator++`(`int`);`

此后,此类代码将无法再编译。

在 C++11 中,可以通过另一种方式实现此解决方案:禁止将此运算符用于右值。为此,请使用引用限定符:

复制代码
Int` `operator++`(`int`) `&`;
`Int` `operator++`(`int`) `&&` `=` `delete`;`

第二个声明是可选的,但可以使代码更清晰。

如果一个函数按值返回一个对象,并且该对象的类型是可移动的,那么它就不能被设为常量,因为这会破坏移动的整个语义。

6. 常量的引用和指针 - 更多细节

常量的引用和指针具有一些有趣且并非完全显而易见的性质。本节主要讨论引用;指针将在最后,即 6.5 节中简要介绍。

6.1 初始化和临时物化

请记住,引用必须始终初始化。显然,对常量的引用可以用常量本身初始化,也可以用同一个对常量的引用来初始化。此外,对常量的引用可以用非常量变量初始化,也可以用对非常量的引用来初始化。

更有趣的是,常量引用可以用右值初始化,也就是说,可以用一个有值但并非由该变量表示的表达式来初始化。此外,常量引用还可以用任何类型隐式转换为其所引用类型的表达式来初始化。在这种情况下,会创建一个对应类型的临时隐藏变量,引用将指向该变量。该变量的生命周期保证不短于引用本身的生命周期。这被称为临时物化。

复制代码
class` `X`
{
    `int` `m_A`, `m_B`;
`public`:
    `X`() : `m_A`(`0`), `m_B`(`0`) {}
    `X`(`int` `a`) : `m_A`(`a`), `m_B`(`0`) {}
    `X`(`int` `a`, `int` `b`) : `m_A`(`a`), `m_B`(`b`) {}
};

`const` `X` `&r1` `=` `1`; `// до C++11`
`const` `X` `&r2`{};
`const` `X` `&r3`{ `3` };
`const` `X` `&r4`{ `4`, `5` };`

在这个例子中,除了 `[ r1]` 之外的所有引用都使用了 C++11 中引入的统一初始化语法。在所有情况下,都会创建一个类型为 `[]` 的隐藏变量X,并使用相应的参数进行初始化。但是,如果类构造函数X声明为 `[ explicit]`,则这些声明将无效,因为参数列表到类型 `[]` 的隐式转换将被禁止X。在这种情况下,您可以这样声明:

复制代码
const` `X` `&s2` `=` `X`();
`const` `X` `&s3` `=` `X`(`3`);
`const` `X` `&s4` `=` `X`(`4`, `5`);`

由于初始化表达式是右值,因此这里也执行临时物化。

当复制对相应临时变量的引用时,可能会出现临时物化问题。编译器不会跟踪副本的生命周期,而副本的生命周期可能比临时变量的生命周期更长。在这种情况下,副本就变成了悬空引用,即指向已删除变量的引用。第 6.3 节和 6.4 节将给出示例。

6.2 函数参数

常量引用经常被用作函数参数类型。

复制代码
int` `Foo`(`const` `T` `&x`); `

此选项不允许修改参数,这意味着在函数体内,参数引用的变量x被视为常量。没有特殊的类型要求T。传递参数的开销等同于传递指向常量的指针T,也就是说,它是常量。任何可用于初始化类型为常量的引用的表达式都可以用作参数T。如果在函数调用期间对参数求值时发生临时实例化,则相应临时变量的生命周期不会小于函数调用所在表达式的生命周期(最大生命周期)。

通过引用常量传递支持多态性,参数的类型可以从参数类型派生而来,但指向虚函数表的指针将像参数一样"正确"传递。

6.3 函数返回值

函数返回值可以引用常量。但是,必须清楚地理解这个引用指向的是什么,以及它的生命周期是否比它所指向的对象更长,否则它可能会变成悬空引用。

考虑这样一个例子:一个函数具有常量引用类型的参数,并使用其中一个参数作为返回值。

复制代码
template<typename` `T>`
`const` `T` `&` `Min`(`const` `T` `&x`, `const` `T` `&y`)
{
    `return` `x` `<` `y` `?` `x` : `y`;
}`

如果函数返回值的参数是左值,则函数将返回对该值的引用。如果参数是右值,则会发生临时实例化,并且相应临时变量的生命周期不会超过调用该函数的表达式的生命周期(最大生命周期)。在这种情况下,如果函数的返回值用于初始化引用,则该临时变量的析构函数将在初始化后立即被调用。继续使用此类引用是不正确的,并可能导致未定义行为。如果在调用该函数的表达式内部使用返回值,则不会出现此问题。

给出的例子并非某种"病态";标准库中的一些函数模板就是这样实现的,例如:std::min(),,std::max()

6.4 班级成员

常量引用类型的非静态类成员也可能产生悬空引用。这类成员必须在构造函数中初始化,而当使用右值参数进行初始化时,必然会出现此问题。例如:

复制代码
class` `R`;

`class` `X`
{
    `const` `R` `&m_R`;
`public`:
    `X`(`const` `R` `&r`) : `m_R`(`r`) { `/* ... */` }
`// ...`
};`

如果使用右值初始化类实例X,则会发生临时变量的实例化。对相应临时变量的引用将在实例的生命周期内有效,但临时变量本身会在构造函数调用后立即被删除。为了防止这种初始化,可以通过将相应的构造函数声明为已删除(deleted)来禁止在构造函数中使用右值参数。

复制代码
X`(`R` `&&`) `=` `delete`;`

使用左值变量初始化此类实例时,还必须确保该变量的生命周期不小于实例的生命周期。

6.5 指向常量的指针

与常量引用不同,常量指针不需要强制初始化。指针可以为空,并且支持赋值,这意味着可以更改它指向的变量。常量指针可以指向非常量变量。

复制代码
const` `int` `x` `=` `1`;
`int` `y` `=` `2`;
`const` `int` `*p` `=` `nullptr`;
`p` `=` `&x`;
`p` `=` `&y`;`

指向常量的指针不支持临时实例化,因为取地址运算符&不能应用于右值。

指向常量的指针通常用作函数参数,用于传递一个元素不会被修改的数组。在这种情况下,函数会收到指向数组第一个元素的指针,以及通常通过另一个参数传递的数组大小。

复制代码
int` `Foo`(`const` `T` `*a`, `int` `n`);`

7. 其他

7.1 本地和全局绑定

常量具有局部绑定,这意味着常量的名称仅在声明它的文件中可见。因此,不同的文件可以包含同名但值不同的常量,它们之间不会发生任何冲突。

如有必要,可以使用常量关键字实现全局链接extern

复制代码
`
`extern` `const` `int` `ExtConst` `=` `42`;

`extern` `const` `int` `ExtConst`;`

7.2 具有常量非静态成员的类

编译器不会为这类类生成赋值运算符。程序员可以自定义赋值运算符,但如何正确实现它并不完全清楚。

可以设计一个所有成员都是常量的类。这种类的实例本质上总是常量。其他编程语言中也有类似的解决方案,但在 C++ 中并不常见。

如果类的常量非静态成员在声明时通过编译时求值的表达式进行初始化,则此类成员对于该类的所有实例将具有相同的值,因此,它们应该声明为static const,或者更好的是,声明为static constexpr(参见第 8.1 节)。

联合体成员也可以是常量。这样,你就可以设计一些联合体,其中一些成员用于设置值,而另一些成员是只读的。

8. constexpr 关键字

编译时恒定性(可以看作是元编程的一部分)的概念被 C++ 社区认可为一个很有前景的概念,并得到了进一步发展。因此,C++11 中引入了一个新的关键字constexpr。它可以在以下情况下使用:

  1. 将变量、数组和模板声明为变量。
  2. 声明自由函数、静态成员函数、函数模板和 lambda 表达式。
  3. 声明类的非静态成员函数。

8.1 变量

全局变量、命名空间变量、局部变量以及静态类成员都可以声明为 `init` 类型constexpr。此类变量必须在声明时使用编译时求值的表达式进行初始化。以下是一些示例:

复制代码
constexpr` `int` `MagicNumber` `=` `54`;

`class` `X`
{
    `static` `constexpr` `double` `PI` `=` `3.14`;
`// ...`
};`

可以额外定义静态类成员,但这并非必需,仅当需要引用或指针指向此类变量时才需要。定义期间不能重用初始化值。

复制代码
constexpr` `double` `X::PI`;`

不能声明为非constexpr静态类成员,原因见第 7.2 节。

声明时,可以使用 ` auto.`。可以使用constexpr字面量类型的类型被视为字面量类型。内置类型、聚合类型和一些非平凡类型在满足额外条件时也是字面量类型。此问题将在 8.4 节中详细讨论。

声明为 `int` 的变量constexpr可以在任何需要编译时表达式的上下文中使用。整型变量可用于定义数组的大小、作为非类型模板参数、定义枚举元素的值或确定对齐方式。声明为 `int` 的变量constexpr是更现代的编译时常量形式,将在 1.3 节中讨论。

8.2 变量模板

C++14 引入了变量模板,方便声明constexpr变量族。变量模板支持特化(完全特化和部分特化),可以根据变量的类型灵活地自定义其值(参见 [VJG])。以下是一些示例:

复制代码
template<typename` `T>` 
`constexpr` `T` `MaxVal`;

`template<>` 
`constexpr` `char` `MaxVal<char>` `=` `127`;

`template<>` 
`constexpr` `short` `MaxVal<short>` `=` `32767`;

`template<>` 
`constexpr` `int` `MaxVal<int>` `=` `2147483647`;

`template<typename` `T>` 
`constexpr` `bool` `IsPtr` `=` `false`;

`template<typename` `T>` 
`constexpr` `bool` `IsPtr<T*>` `=` `true`;

`std::cout`
`<<` (`int`)`MaxVal<char>` `<<''`
`<<` `MaxVal<short>` `<<''`
`<<` `MaxVal<int>` `<<''`
`<<` `std::boolalpha`
`<<` `IsPtr<int*>` `<<''`
`<<` `IsPtr<int>` `<<` `'\n'`;`

8.3 函数

可以声明constexpr自由函数和静态类成员函数。对于这些函数,如果参数在编译时求值,则返回值也必须在编译时求值。返回值和参数必须是字面量类型。constexpr函数通常不会抛出异常,因此应该声明为 `undefined` noexcept。函数调用constexpr可以用于任何需要在编译时求值的表达式的上下文中。显然,在这种情况下,函数定义在调用时必须是可见的。

复制代码
constexpr` `int` `Square`(`int` `n`) `noexcept` { `return` `n` `*` `n`; }
`int` `a`[`Square`(`2`)];`

如果函数调用constexpr在编译时不对其参数进行求值,则此类调用可以像普通函数调用一样使用。

Lambda 表达式也可以使用constexpr. 以下是一个示例:

复制代码
auto` `square` `=` [](`auto` `n`) `constexpr` { `return` `n` `*` `n`; };

`int` `a`[`square`(`2`)];`

函数模板也可以声明为 ` constexpr.`。以下是 C++17 标准库中的示例:

复制代码
`
`template<typename` `M`, `typename` `N>`
`constexpr` `common_type_t<M`, `N>` `gcd`(`M` `m`, `N` `n`) `noexcept`;


`template<typename` `M`, `typename` `N>`
`constexpr` `common_type_t<M`, `N>` `lcm`(`M` `m`, `N` `n`) `noexcept`;

`template<class` `T>`
`constexpr` `const` `T&` `clamp`(`const` `T` `&v`, `const` `T` `&lo`, `const` `T` `&hi`);

`template<class` `T>`
`constexpr` `const` `T&` `min`(`const` `T` `&a`, `const` `T` `&b`);`

C++11constexpr对函数的实现施加了许多限制,这些限制在 C++14、C++17 和 C++20 中基本被取消。从概念上讲,constexpr函数接近于纯函数,即返回值仅取决于其参数值的函数。

在 C++20 中,函数可以赋予一个说明符consteval,这是一种更强的constexpr函数形式,只能使用编译时参数调用。

8.4 非静态类成员函数

非静态类成员函数也可以声明为 ` constexpr.`。以下是一个简单的示例:

复制代码
class` `Int`
{
    `int` `m_Value`;
`public`:
    `constexpr` `Int`(`int` `val`) `noexcept` : `m_Value`(`val`) {}
    `constexpr` `int` `Value`() `noexcept` { `return` `m_Value`; }
};

`constexpr` `Int` `ii`(`4`);
`int` `aa`[`ii`.`Value`()];`

声明为 `const` 的构造函数constexpr必须初始化所有类成员,并且满足constexpr函数要求。其他constexpr成员函数也必须满足函数要求。此外,这些成员函数默认是 `const` 的,因此必须满足相应的约束,但可以省略constexpr`const` 限定符。const

这个例子就是一个字面量类的例子。简单来说,字面量类就是一个添加了constexpr构造函数和其他constexpr成员函数的普通类。字面量类相比普通类还有其他一些"放宽"的限制,但这属于非常具体的话题。

在 C++11 和 C++14 中,有一个类型属性可以用来判断类型是否为字面量。该表达式的值为真std::is_literal_type<Т>::value表示类型为字面量,否则为假。此类型属性在 C++17 中被弃用,并在 C++20 中被移除。true``T``false

9. 结果

建议广泛使用const变量constexpr、常量引用和指针、常量成员函数以及常量迭代器。这将提高代码的可靠性,改善代码的可读性,并扩展代码的使用范围。

相关推荐
信工 18021 小时前
Linux驱动开发——SPI
linux·驱动开发
b***59431 小时前
在 Ubuntu 22.04 上安装和配置 Nginx 的完整指南
linux·nginx·ubuntu
赖small强2 小时前
【音视频开发】Linux UVC (USB Video Class) 驱动框架深度解析
linux·音视频·v4l2·uvc
多恩Stone2 小时前
【系统资源监控-1】Blender批量渲染中的负载、CPU、GPU和进程管理
linux·python
莽夫搞战术2 小时前
Linux NAS 迁移避坑指南:放弃 chown -R,ID 映射让权限配置秒完成
linux·服务器
好好沉淀2 小时前
IDEA如何设置以新窗口打开新项目
linux·windows·intellij-idea
大聪明-PLUS2 小时前
C++中变量的声明和初始化
linux·嵌入式·arm·smarc
被制作时长两年半的个人练习生2 小时前
如何调试llama.cpp及判断是否支持RVV
linux·服务器·llama