【Effective C++】<2> const 关键字

[toc]

const 修饰指针变量

对于指针,你可以指定指针本身是否为常量,也可以指定指针所指向的内容是常量,或两者都是,或两者都不是。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// constPointer.h
#ifndef __CONSTPOINTER_H__
#define __CONSTPOINTER_H__

#include <iostream>

char greeting[] = "Hello";

class Widget
{
};

void f1(const Widget *pw); // f1 获取一个指针指向常量的 Widget 对象
void f2(Widget const *pw); // f2 相同


#endif // __CONSTPOINTER_H__
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// constPointer.cpp
#include "constPointer.h"

int main()
{
char *p1 = greeting; // 非常量指针, 非常量数据
const char *p2 = greeting; // 非常量指针, 常量数据
char const *p3 = greeting; // 非常量指针, 常量数据
char * const p4 = greeting; // 常量指针, 非常量数据
const char * const p5 = greeting; // 常量指针, 常量数据

std::cout << "p1 = " << p1 << std::endl;
std::cout << "p2 = " << p2 << std::endl;
std::cout << "p3 = " << p3 << std::endl;
std::cout << "p4 = " << p4 << std::endl;
std::cout << "p5 = " << p5 << std::endl;

return 0;
}

总结:如果 const 出现在星号的左边,那么被指针指向的内容就是常量;如果 const 出现在星号的右边,那么指针本身就是常量;如果 const 出现在星号两边,则表示指针和指针指向的内容都是常量。

迭代器中的 const

STL 的迭代器是以指针为模型的,所以一个迭代器的行为很想一个和 T* 指针。声明一个迭代器的常数就像声明一个指针的常量(即声明一个 T* 常量指针)。迭代器不允许指向不同的内容,但它指向的内容可以被修改。如果你想要一个迭代器指向不能被修改的东西(即 STL 类似于一个 const T* 指针),则需要一个 const_itrerator

1
2
3
4
5
6
7
8
9
10
// constIterator.h
#ifndef __CONSTITERATOR_H__
#define __CONSTITERATOR_H__

#include <iostream>
#include <vector>

std::vector<int> vec = {1, 2, 3, 4, 5};

#endif // __CONSTITERATOR_H__
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// constIterator.cpp
#include "constIterator.h"

int main()
{
// 1. 指针为非常量,内容为非常量
std::vector<int>::iterator iter1 = vec.begin();
*iter1 = 10; // 正确
++iter1; // 正确

// 2. 指针为常量,内容为非常量
const std::vector<int>::iterator iter2 = vec.begin();
*iter2 = 10; // 正确, 修改指针所指内容
++iter2; // 错误,iter2 为常量指针,不能修改

// 3. 指针为非常量,内容为常量
std::vector<int>::const_iterator iter3 = vec.begin();
*iter3 = 10; // 错误, *iter3 表示指针所指内容为常量,不能修改
++iter3; // 正确,指针非常量,可以改变

// 4. 指针为常量,内容为常量
const std::vector<int>::const_iterator iter4 = vec.begin();
*iter4 = 10; // 错误, *iter4 表示指针所指内容为常量,不能修改
++iter4; // 错误,iter4 为常量指针,不能修改

// 5. 等效于 std::vector<int>::const_iterator iter5 = vec.begin();
auto iter5 = vec.cbegin();

return 0;
}

const 修饰函数返回值

在成员函数上使用 const 的目的是为了确定哪些成员函数可以在 const对象上调用。这样的成员函数很重要,原因有二。首先,它们使一个类的接口更容易理解。知道哪些函数可以修改一个对象,哪些不可以,是很重要的。第二,它们使我们有可能与常量对象一起工作。这是编写高效代码的一个关键方面,提高 C++ 程序性能的基本方法之一是通过引用到常量来传递对象。这种技术只有在有常量成员函数的情况下才是可行的,可以用它来处理产生的常量限定的对象。

关于 const 修饰成员函数主要讲以下三个知识点:

  • const 修饰的成员函数可以被重载。
  • 关于 ”二进制位常量性“ 与 ”逻辑常量性“。
  • 避免重载而导致重复代码。

const 修饰成员变量可以被重载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// overloadConst.h
#ifndef __OVERLOADCONST_H__
#define __OVERLOADCONST_H__

#include <iostream>

class TextBlock {
public:
TextBlock(std::string t) : text(t) {}
~TextBlock() {}

const char& operator[](std::size_t position) const
{
std::cout << "const char& operator[](std::size_t position) const" << std::endl;
return text[position];
}

char& operator[](std::size_t position)
{
std::cout << "char& operator[](std::size_t position)" << std::endl;
return text[position];
}

private:
std::string text;
};

#endif // __OVERLOADCONST_H__
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// overloadConst.cpp
#include "overloadConst.h"

// 在函数中, ctb 是常量
void print(const TextBlock& ctb)
{
// 调用 const TextBlock::operator[]
std::cout << ctb[0] << std::endl;
// ctb[0] = 'x'; // 错误,不能修改一个常量
}

int main()
{
// 调用 TextBlock::operator[]
TextBlock tb("Hello");
std::cout << tb[0] << std::endl; // 正确,只读一个非常量
tb[0] = 'x'; // 正确,修改一个非常量

// 调用 const TextBlock::operator[]
const TextBlock ctb("World");
std::cout << ctb[0] << std::endl; // 正确,只读一个常量
// ctb[0] = 'x'; // 错误,不能修改一个常量

print(tb);

return 0;
}
  • 通过重载 operator[] 并赋予不同版本的返回值类型,你可以对常量和非常量 TextBlocks 进行不同的处理。
  • 常量对象在实际程序中最常出现的是通过指针或引用到常量的结果。例如示例代码中的 void print(const TextBlock& ctb) 函数。
  • 请注意,main() 函数中 ctb[0] = 'x'; 的错误只与被调用的 operator[] 的返回类型有关;对 operator[] 本身的调用都是正常的。错误是由于试图对 const char& 进行赋值。
  • operator[] 的返回数据类型是 char&,不是 char。如果 operator[] 如果只返回一个 char,是不会被编译通过的,因为修改内置数据类型的函数的返回值是不合法的。即使它是合法的,C++ 按值返回对象的事实将意味着一个 tb.text[0] 的副本会被修改,而不是 tb.text[0] 本身。

二进制位常量性与逻辑常量性

一个成员函数为常量意味着什么?有两个普遍的概念:“二进制位常量性”(也称为 “物理常量性”)和 “逻辑常量性”。

二进制位常量性

”二进制位常量性“ 阵营认为,当且仅当一个成员函数不修改对象的任何数据成员(不包括静态成员)时,它才是常量,也就是说它不修改对象内部的任何比特位。比特恒定性的好处是,它很容易被检测到违规行为,编译器只需查看对数据成员的赋值。事实上,逐位不变性是 C++ 对不变性的定义,而且一个常量成员函数不允许修改其所在对象的任何非静态数据成员。

遗憾的是,许多不怎么规范的成员函数都通过了二进制位常量性测试。特别是,一个修改了 “指针所指向的内容” 的成员函数经常不算是常量成员。但如果对象中只有指针,那么称该函数是 “二进制位常量性”,编译器也不会有异议。例如下面的示例代码所示,假设有一个类似于 TextBlock 的类,它将其数据存储为 char* 类型而不是 string类型,因为它需要通过一个不认识 string 对象的 C API 进行交互。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// bitwise.h
#ifndef __BITWISE_H__
#define __BITWISE_H__

#include <iostream>
#include <cstring>

class CTextBlock
{
public:
CTextBlock(std::string txt);
~CTextBlock();

// 二进制位常量性声明,实际并不合适
char& operator[](std::size_t position) const
{
return pText[position];
}
private:
void release();
private:
char *pText;
};

#endif // __BITWISE_H__
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// bitwise.cpp
#include "bitwise.h"

CTextBlock::CTextBlock(std::string txt) : pText(NULL)
{
release();
int len = txt.length();
pText = new char[len + 1];
memset(pText, 0, len + 1);
memcpy(pText, txt.data(), len);
}

CTextBlock::~CTextBlock()
{
release();
}

void CTextBlock::release()
{
if(pText != NULL)
{
delete pText;
pText = NULL;
}
}

int main()
{
const CTextBlock cctb("Hello");
char *pc = &cctb[0];
std::cout << "before modified: pc = " << *pc << std::endl;
*pc = 'J';
std::cout << "after modified: pc = " << *pc << std::endl;
return 0;
}

CTextBlock 类(不恰当地)将 operator[] 声明为一个常量成员函数,尽管该函数返回的是对对象内部数据的引用。注意到 operator[] 的实现并没有以任何方式修改 pText。因此,编译器会很乐意为 operator[] 生成代码。

但是我们却发现在 main() 函数中 *pc = 'J'; 对返回值进行了修改,*pc 可是调用 operator[] 后的返回值。

逻辑常量性

出现上面 “二进制位常量性” 示例代码中问题后出现了 “逻辑常量性” 派。 一个常量成员函数可能会修改它所调用的对象中的比特位,但只是以客户端检测不到的方式。例如,当你的 CTextBlock 类被请求后,可能想缓存文本块的长度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// logic.h
#ifndef __LOGIC_H__
#define __LOGIC_H__

#include <iostream>
#include <cstring>

class CTextBlock
{
public:
std::size_t length() const;

private:
char *pText;
std::size_t textLength; // 最近一次计算文本区块长度
bool lengthIsValid; // 目前长度是否有效
};

#endif // __LOGIC_H__
1
2
3
4
5
6
7
8
9
10
11
12
// logic.cpp
#include "logic.h"

std::size_t CTextBlock::length() const
{
if (!lengthIsValid) {
// 错误,const 成员函数内不能给 textLength 和 lengthIsValid 赋值
textLength = std::strlen(pText);
lengthIsValid = true;
}
return textLength;
}

length() 的实现不是 “二进制位常量性”,因为 textLengthlengthIsValid 都可能被修改。这两个数据被修改对 const CTextBlock 对象而言虽然可接受,但编译器不同意。

解决办法也很简单,利用 C++ 的一个与 const 相关的摆动场:mutable(可变的)。mutable 释放掉非静态成员变量的 “二进制位常量性” 约束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// logic.h
#ifndef __LOGIC_H__
#define __LOGIC_H__

#include <iostream>
#include <cstring>

class CTextBlock
{
public:
std::size_t length() const;

private:
char *pText;
mutable std::size_t textLength; // 最近一次计算文本区块长度
mutable bool lengthIsValid; // 目前长度是否有效
};

#endif // __LOGIC_H__

避免常量和非常量成员重复

mutable 是一个很好的解决方案,它解决了二进制位常量性非我所欲的问题,但它并没有解决所有与常量相关的困难。例如,假设 TextBlock(和 CTextBlock)中的 operator[] 不仅返回对相应字符的引用,它还执行边界检查,记录访问信息,甚至可能进行数据完整性验证。把所有这些放在常量和非常量的 operator[] 函数中(不要担心,现在有隐式内联长度的函数–见实验《透彻内联》)会产生这种怪异的现象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// avoidDuplication.h
#ifndef __AVOIDDUPLICATION_H__
#define __AVOIDDUPLICATION_H__

#include <iostream>
#include <cstring>

class TextBlock
{
public:

const char& operator[](std::size_t position) const
{
// 边界检查
// 日志数据访问
// 验证数据完整性
return text[position];
}

char& operator[](std::size_t position)
{
// 边界检查
// 日志数据访问
// 验证数据完整性
return text[position];
}
private:
std::string text;
};

#endif // __AVOIDDUPLICATION_H__

看到上面示例代码,你能说代码重复,以及随之而来的编译时间、维护和代码漏洞等问题吗?当然,我们可以把所有的边界检查等代码移到一个单独的成员函数中(私有的),两个版本的 operator[] 都会调用。但你仍然会重复调用该函数,并且你仍然有重复的返回语句代码。

你真正想做的是实现一次 operator[] 功能并使用它两次。也就是说,你想让一个版本的 operator[] 调用 const operator[]。这就使得我们需要去除常量性。

去重复代码

作为一般规则,转型是一个糟糕的想法,在实验《尽量少类型转换》整个实验来讨论这个问题,但代码重复也不是什么好事。在这种情况下,const operator[] 所做的与 operator[] 所做的事情相同,只有常量版本返回值类型受到常量约束。在这种情况下,将返回值中的 const 转型是安全的。因为不论调用 operator[] 还是 const operator[] 都一定首先有个非常量对象,否则就不能够调用非常量函数。所以用 operator[] 调用 const operator[] 是一个避免代码重复的安全做法,即使过程中需要一个转型动作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// safeCast.h
#ifndef __TEXTBLOCK_H__
#define __TEXTBLOCK_H__

#include <iostream>
#include <cstring>

class TextBlock
{
public:

const char& operator[](std::size_t position) const
{
// 边界检查
// 日志数据访问
// 验证数据完整性
return text[position];
}

char& operator[](std::size_t position)
{
return const_cast<char&>(static_cast<const TextBlock&>(*this)[position]);
}
private:
std::string text;
};

#endif // __TEXTBLOCK_H__
  • 如示例代码所示,代码有两个转型,而不是一个。第一次用来为 *this 添加 const 使得 operator[] 可以调用 const operator[],第二次则是从 const operator[] 的返回值中去除常量性。
  • 示例代码中使用 operator[] 调用 const operator[],但如果只是单纯的调用会递归调用自己,那么代码会一直重复调用。为了避免无穷递归,调用时必须明确指出调用的是 const operator[]
  • C++ 缺乏直接的语法可以去调用,因此将 *this 从其原始类型 TextBlock& 转型为 const TextBlock&
  • 使用 static_cast 添加常量性,强制进行了一次安全转型;再使用 const_cast 去除常量性。

const 修饰成员函数


【Effective C++】<2> const 关键字
https://hodlyounger.github.io/D_立志博览群书/《Efficient C++》/【Effective CPP】2_const关键字/
作者
mingming
发布于
2023年10月27日
许可协议