《C++编程惯用法——高级程序员常用方法和技巧》——2.7 Const

简介:

本节书摘来自异步社区出版社《C++编程惯用法——高级程序员常用方法和技巧》一书中的第2章,第2.7节,作者: 【美】Robert B. Murray ,更多章节内容可以访问云栖社区“异步社区”公众号查看。

2.7 Const

许多C++程序员在开始使用const时都是用它来定义一些常数;例如将:

//C版本:
#define BUFF_LENGTH 1024
int buffer[BUFF_LENGTH];

写成:

//C++版本:
const int BUFF_LENGTH = 1024;
int buffer[BUFF_LENGTH];

这样做可以获得的好处是:编译器(或者其他工具)可以知道BUFF_LENGTH的名字和类型。而且这样做不会给程序的执行时间和尺寸上带来什么额外的开销,如果我们不去使用BUFF_LENGTH的地址的话,编译器也就不需要为它申请专门的存储空间。

然而,const能做的并不仅仅是定义常数那么简单。通过声明一个“指向常量的指针”,我们就可以确保该指针指向的对象不会被改变。在本节中,我们将向读者展示指向常量的指针(或者引用)是如何影响我们的代码的。

请记住一点:将某件事物声明为const(或者是指向const的指针或者引用)会引起额外的编译期检测,但它并不会导致编译器产生额外的代码。

2.7.1 常量引用参数

在C++中,函数参数是以值的方式进行传递的。这意味着在被调用的函数中,存在着一份实际参数的拷贝;我们在被调函数中对该拷贝进行的改变不会反映到调用函数中去。

当一个自定义类型的对象以传值的方式传递到函数中去时,通过调用该类型的复制构造函数,它将和函数的其他实参一样被复制。当函数返回时,编译器会产生一些代码(调用析构函数)来摧毁这份拷贝。这样的复制操作可能会造成额外的开销,尤其是当被调函数又将该对象传递给其他函数时更是如此。在这种情况下,每个最外层的调用都可能会产生该对象的好几份拷贝(以及相应的构造和析构代码)。

在大多数情况下,即便对象在概念上是以传值的方式传递给函数,被调函数可能也不需要有着它自己的对于该对象的独立拷贝,它可以提供一个常量引用来使用这个对象:

//正确但运行缓慢的代码:
//不必要的Telephone_number的拷贝:
void
dial(Telephone_number tn){
//此处忽略细节
}

//改善后的代码:使用了一个常量引用:
void
dial(const Telephone_number& tn){
//此处忽略细节
}

上面的第二份代码的速度要快一些,因为它避免了对输入参数进行复制并在函数返回时摧毁该复制的操作。此处使用const阻止了被调函数无意中对调用函数中对象的值进行修改。

使用引用给用户带来了一些重要的语法甜头:他们不再需要去记忆哪些参数是以指针的方式传递给了函数,哪些参数是以值的方式传递给了函数。如果在概念上来说,参数是以值的方式传递给函数,我们就可以以上面的方式来写调用语句,而“参数是以引用的方式传递给函数”这个实现细节就被隐藏起来了。

2.7.2 常量参数和常量指针

不同的C++程序员对于const的态度不一样。有些人把它看作是在编译期间寻找bug的一个重要工具;其他人则认为相对它的好处来说,const带来的麻烦要更多一些,并因此不去使用它。如果我们正在编写将要被他人使用的代码,第二种态度就不适合我们:即使我们自己不使用const,其他使用我们编写的类的人可能也会使用它,因此我们不得不将一些适当的事物声明为const以允许其他人的使用。

在那些接受指针参数的函数中,这种情况出现得最多。如果函数只是通过指针来读取(它并不会向被指向的对象存储或者更改它的内容),在函数声明时,我们应该将该参数声明为一个指向常量的指针:

class String {
publiC:
   String(const char*="");
//此处忽略细节
};

上面的声明保证了String的构造函数不会通过它的指针参数进行存储活动。(任何试图在String的构造函数中对它进行的存储活动都将导致一个编译期错误。)如果我们把String的构造函数声明为带有一个类型为char而不是const char的参数,那么所有有着常量指针的用户就无法用这个构造函数来构建一个String对象:

//在String.h中:
class String {
public:
   String(char* = ""); //应该为"const char*"
//此处忽略细节
};

//在用户代码中:

main() {
   const char* hello = "hello world";
   String s(hello);    //编译期错误:
                //找不到合适的String构造函数
}

同样的情况也适用于接受引用参数的函数:如果函数不会通过引用来存储内容,那么它应该接受一个常量引用作为其参数。常量引用参数也使得将一个指向未命名的临时对象的引用传递给函数成为了可能。

Thing get_a_thing();

void look_at_thing(const Thing&);

void change_thing(Thing&);

look_at_thing(get_a_thing ());   //OK
change_thing(get_a_thing());    //编译期错误

对于change_thing的调用会产生一个编译期的错误:将一个未命名的临时对象作为一个非常量指针传递给函数是非法的。如果在被调函数中对引用参数的值进行了修改,但调用函数却忽略了这种修改,我们认为这种行为是一种bug;因此C++中也就增添了这么一条规则来禁止这种bug的产生。如果我们真的想那么做的话,我们就必须得创建一个具名对象:

Thing t(get_a_thing());
change_thing (t);  //OK

声明一个指向常量的指针关注的是该指针,而不是该指针指向的空间。编译器并不能确保被指向的数据不会被改变;它能确保的是,数据不会是通过该指针被改变的。我们仍然可以使用其他方式来改变被指向的对象的值:

Void
do_callback(const int* ip, void(*callback)()) {
   cout << *ip << endl;
   (*callback)();
   cout << *ip<< endl;
}

即使ip是一个指向const int的指针,我们仍然不能保证这两次打印的数字会是一样的。如:

int i = 5;
void 
bump_i(){
   ++i;
}

main(){
   do_cal1back (&i, bump_i);
}

它就将打印:

5
6

在两次打印之间,那个回调函数会修改i的值。

一个语法上的小缺点
当我们在do_callback中通过函数指针来调用那个回调函数时,我们先对函数指针进行解引用,然后再调用该函数:

(*cal1back)();

然而,如果我们直接通过()操作符来“调用”该函数,我们将得到和前面一样的结果:

callback();

这两种方式得到的代码将完全一样。

这只是一个语法上的甜头而已,我并不推荐大家这么做。当我们使用第一种形式时,我们可以很清楚地知道函数是通过函数指针来调用的;如果我们使用第二种形式,我们必须了解“函数”的类型实际上也就是“函数指针”的类型。相比而言,少输入几个字符所得来的好处还不值得我们去为它而生成难以理解的代码。

2.7.3 常量成员函数

那些不会修改对象值的函数应该被声明为const(详情参见后面的“回顾”)。这使得其他人可以使用我们编写的类来创建常量对象,并使用编译器来确保对这些对象所进行的成员函数调用不会修改它的值。

在未命名的临时对象上调用非常量成员函数
即使我们不能传递一个指向非常量未命名临时对象的引用,我们还是可以合法地在一个未命名的临时对象上调用一个非常量成员函数:

class String {
public:
   void capitalize(); //非常量成员函数
};
//…
String make_up_name();
make_up_name().capitalize();  // 可以但不应该这么调用

这种做法凸显了C++语言定义的一个失误,我期望ISO/ANSI C++标准委员会能够在随后的标准制定过程中把它给改正;不管如何,我们都不应该使用这种用法。如果我们希望从对象来调用一个非常量的成员函数,我们必须明确地定义这个对象并给它一个名字:

String name(make_up_name ());
name.capitalize();//稍好的做法

这将保证该名字所代表的对象在程序离开当前定义它的语句块前都不会被摧毁。

回顾:常量成员函数

通过在函数的声明体和定义体的参数列表后面添加关键字const,我们可以把一个成员函数声明为一个常量成员函数:

//在String.h中:
class String {
public:
   //此处忽略细节
   int langth() const;
   void capitalize(); //非常量成员函数
};
//在String.c中:
int 
String::length() const {
//此处忽略细节
}
 
void 
String::capitalize() { //非常量成员函数
//此处忽略细节
}

我们只能对常量对象调用常量成员函数:

const String S("hello");
int len = s.length(); //OK
s.capitalize();//编译期错误:对常量对象调用非常量成员函数
 
String t("world");    //非常量String
len = t.length();     //OK
t.capitalize();      //OK

在常量成员函数的定义体中,对象的所有数据成员都是常量,“this”指针也是一个“指向常量对象的常量指针”,而不是“指向对象的常量指针”。
会改变对象状态的常量成员函数
C++的语言规则确保:除了明确地使用了类型转换,常量成员函数不会修改对象的状态(数据成员)。然而,某些在概念上为常量的操作可能也会改变对象中某些成员的值;对于这种情况,我们应该把它们作为实现细节向用户隐藏起来。通过使用这种方法,用户就可以在不清楚实现细节会修改对象中的某些私用数据成员的情况下,对一个常量对象进行某些在概念上来说不会更改对象状态的操作。

例如,假设我们正在使用的Complex类中存储有值的极坐标形式。我们可能需要将它的笛卡儿坐标的值也缓冲在对象中,以节约以后对它们重复计算所带来的运行时间。现在,每个对象都包含有一个布尔值,我们用它来判断缓冲值是否有效:

typedef unsigned char Boolean;
class Complex {
private:
   double r,theta;
   double real_cache,imag_cache;
   Boolean real_cache_valid;
   Boolean imag_cache_valid;
public:
   Complex(double real,double imag);
   double real_part()const;
   void real_part(double);

   double imag_part()const;
   void  imag_part(double);
};

我们在构造函数中将缓冲值标志位设为0,以表明缓冲值目前是无效的:

#include <math.h>
Complex::Complex(double re, double im)
:r(sqrt( re*re + im*im )),
 theta(atan2(im,re)),
 real_cache_valid(0),
 imag_cache valid(0)
{}

在概念上来说,我们应该把那些用来获取(而不是设置)这些值的函数定义为常量成员函数,但是它们实际上也会修改对象中的缓冲值:

double
Complex::real_part() const{
   if(!real_cache_valid) {
    (double&)real_cache = r*sin(theta);
    (Boolean&)real_cache_valid = 1;
  }
  return real_cache;
}

为了在一个常量成员函数中对数据成员进行修改,我们必须使用类型转换来去除该成员的常量性。在C++中,这种做法不但合法,而且只要类中带有一个构造函数,它的行为就将正确无误。(如果类中没有构造函数,那么我们用来去除常量性的类型转换将得到未定义的结果;之所以有这样的规则是因为我们希望编译器能够将类似于const int这样的事物放置到ROM中去。)

相关文章
|
存储 安全 编译器
第二问:C++中const用法详解
`const` 是 C++ 中用于定义常量的关键字,主要作用是防止值被修改。它可以修饰变量、指针、函数参数、返回值、类成员等,确保数据的不可变性。`const` 的常见用法包括:
|
安全 程序员 编译器
【实战经验】17个C++编程常见错误及其解决方案
想必不少程序员都有类似的经历:辛苦敲完项目代码,内心满是对作品品质的自信,然而当静态扫描工具登场时,却揭示出诸多隐藏的警告问题。为了让自己的编程之路更加顺畅,也为了持续精进技艺,我想借此机会汇总分享那些常被我们无意间忽视却又导致警告的编程小细节,以此作为对未来的自我警示和提升。
1701 124
|
存储 C++ UED
【实战指南】4步实现C++插件化编程,轻松实现功能定制与扩展
本文介绍了如何通过四步实现C++插件化编程,实现功能定制与扩展。主要内容包括引言、概述、需求分析、设计方案、详细设计、验证和总结。通过动态加载功能模块,实现软件的高度灵活性和可扩展性,支持快速定制和市场变化响应。具体步骤涉及配置文件构建、模块编译、动态库入口实现和主程序加载。验证部分展示了模块加载成功的日志和配置信息。总结中强调了插件化编程的优势及其在多个方面的应用。
1527 167
|
存储 缓存 C++
C++ 容器全面剖析:掌握 STL 的奥秘,从入门到高效编程
C++ 标准模板库(STL)提供了一组功能强大的容器类,用于存储和操作数据集合。不同的容器具有独特的特性和应用场景,因此选择合适的容器对于程序的性能和代码的可读性至关重要。对于刚接触 C++ 的开发者来说,了解这些容器的基础知识以及它们的特点是迈向高效编程的重要一步。本文将详细介绍 C++ 常用的容器,包括序列容器(`std::vector`、`std::array`、`std::list`、`std::deque`)、关联容器(`std::set`、`std::map`)和无序容器(`std::unordered_set`、`std::unordered_map`),全面解析它们的特点、用法
C++ 容器全面剖析:掌握 STL 的奥秘,从入门到高效编程
|
存储 机器学习/深度学习 编译器
【C++终极篇】C++11:编程新纪元的神秘力量揭秘
【C++终极篇】C++11:编程新纪元的神秘力量揭秘
|
存储 算法 C++
深入浅出 C++ STL:解锁高效编程的秘密武器
C++ 标准模板库(STL)是现代 C++ 的核心部分之一,为开发者提供了丰富的预定义数据结构和算法,极大地提升了编程效率和代码的可读性。理解和掌握 STL 对于 C++ 开发者来说至关重要。以下是对 STL 的详细介绍,涵盖其基础知识、发展历史、核心组件、重要性和学习方法。
|
存储 安全 算法
深入理解C++模板编程:从基础到进阶
在C++编程中,模板是实现泛型编程的关键工具。模板使得代码能够适用于不同的数据类型,极大地提升了代码复用性、灵活性和可维护性。本文将深入探讨模板编程的基础知识,包括函数模板和类模板的定义、使用、以及它们的实例化和匹配规则。
|
消息中间件 存储 安全
|
IDE Java 程序员
C++ 程序员的 Java 指南
一个 C++ 程序员自己总结的 Java 学习中应该注意的点。
215 5
|
安全 程序员 编译器
【C++篇】继承之韵:解构编程奥义,领略面向对象的至高法则
【C++篇】继承之韵:解构编程奥义,领略面向对象的至高法则
314 12