C++ Primer 读书笔记

做点什么吧

第一章 开始

  • 本书采用的C++版本为 C++11

编写一个简单的C++程序

  • 就像Java有程序入口一样,C++程序也有入口,它们的入口函数都是 main 函数。
  • 在执行成程序的时候,系统会调用 main 来运行程序。

Example:

1
2
3
int main() {
return 0;
}

我保留了Java的书写习惯,将左大括号放在了函数的一行,(大括号另起一行的都是异教徒!)。

这是一个C++里最简单的函数,这段代码的作用是返回给操作系统一个值:0 。

C++函数构成包括四个部分:

  • 返回值类型
    • main函数的返回值类型必须为 int,即整数类型。 int类型是一种内置的数据类型。
  • 函数名
    • 主函数名字为main函数,自定义的函数名字可以自行取,而且main函数是程序的一个入口点。
  • 参数列表
    • 本例中没有带有任何参数。
  • 函数体
    • 大括号括起来的语句块即为函数体,此例中函数体中只包含一条语句。
    • 此语句是结束词函数的执行,并向调用者返回一个值;此返回值类型必须与函数的返回值类型相同

重要概念:数据类型

因为Java是从C++演变而来,所以对于数据类型也有很深的认识:
类型是程序设计的最基本的概念之一,一种数据类型不仅仅定义了数据元素的内容,还定义了这类数据上可以进行的运算
程序所处理的数据都保存在变量中,而每个变量都有自己的数据类型。

编译、运行程序

编写好程序之后 我们就需要去编译它。

编译环境我用的是CMake;CMake的使用,我参考了这份资料CMake实践

输入输出

C++并没有定义输入输出语句,但是它有一个全面的标准库(std)来提供IO机制以及其他操作。

标准输入输出对象

本示例使用的 iostream 库,
iostream 库中包含两个基础类型 istream 和 ostream,分别表示输入流和输出流。
‘流’想要表达的是:随着时间的推移,自复式顺序生成或者消耗的。

标准库定义了四个IO对象。

  • 为了处理输入,我们使用一个名为cin的istream类型的对象。这个对象成为标准输入。

  • 为了处理输出,我们使用一个名为cout的ostream类型的对象。这个对象成为标准输出。

  • 为了处理警告和错误消息,我们使用一个名为cerr的ostream类型的对象,我们称之为标准错误

  • clog则用来输出程序运行时的一般性信息。

一个使用 IO 库的程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

#include <iostream> //告诉编译器我们要使用的库为 iostream
using namespace std; // using 指令可以告诉我们使用命名空间std,std中包含了 包括cout/cin等常用的函数。
int main(){
cout << " Enter two numbers: "<<endl;

//定义两个int类型的变量 v1,v2
int v1 = 0, v2 = 0;

// 使用输入功能 cin,运算符 >>:从键盘接受输入,使接收到的输入内容接收到v1,v2中
cin >> v1 >> v2;

// 使用输出功能 cout,运算符 <<:输出内容到屏幕,可以将cout寓意为屏幕,箭头寓意为输出到cout/即屏幕。
cout << "The sum of " << v1 << " and " << v2 << " is " <<v1+v2 << " ." <<endl;

return 0;
}

  • #include (iostream) # 告诉编译器我们要使用的库为 iostream
  • std指代库iostream中的命名空间。
    • 当useing编译指令与变量定义同时出现,优先使用定义。
  • endl 则被称之为 操纵符 的特殊值,写入endl的效果是结束当前行。并将与设备关联的缓冲区(buffer)刷到设备中。
  • 缓冲刷新操作可以保证截至到目前的所有输出都写入到输出流中。
  • 如果在调试的时候,我们不应该去执行endl将它写入到缓冲区中,应该一直使这个输出流报纸刷新。

注释

注释可以帮助人类读者理解带有注释的程序。在编译的时候,编译器会自动忽略注释。

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include (iostream)
/**
* 这是多行注释
* 请注意 注释 界定符不能嵌套
*
*/
int main(){
std::cout << " Enter two numbers: "<<std::endl;

// 这是单行注释。
int v1 = 0, v2 = 0;
std::cin >> v1 >> v2;

std::cout << "The sum of " << v1 << " and " << v2 << " is " <<v1+v2 << " ." <<std::endl;

return 0;
}

运算符:

  • (<<) 输出运算符 // 还有其他含义
  • (>>) 输入运算符 // 还有其他含义
  • (<=) 小于等于
  • (>=) 大于等于

控制流

字面意思:控制程序的运行路径。

while语句

while语句会反复执行一段带吗,直到给定的条件为假为止。
Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>

int main(){

int sum = 0, val = 1;

while (val <= 1000) {
// 只要val的值小于10,循环就会持续执行,
sum += val;
// 将sum+val赋值给sum
++val;
//val+1
}
std::cout << sum <<std::endl;
return 0;
}

复合赋值运算符:

  • += 将右侧的运算对象加到左侧运算对象上。

前缀递增运算符

  • ++ 前缀++可以作为左值来使用,将运算的对象+1

1.4.2 for语句

上个while例子中的循环条件检测变量,再循环体中增加变量的模式使用非常频繁,所以C++专门定义了第二种循环语句:for语句。

使用for语句重写从1加到10的程序:
Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

int main () {

int num1 = 0, num2 = 0;
std::cout << "输入 num1: "<<std::endl;
std::cin >> num1;
std::cout << "输入 num2: "<<std::endl;

for (std::cin >> num2; num2>=num1; ++num1) {
std::cout << num1 <<std::endl;
}

return 0;
}

读取数量不定的输入数据

如果我们预先不知道要对多少个数求和,这就需要不断读取数据直至没有新的数据输入为止。

1
2
3
4
5
6
7
8
9
10
#include <iostream>

int main() {
int sum = 0, value = 0;

while (std::cin >> value){
sum += value;
}
std::cout << "sum is " << sum << std::endl;
}

因为 value定义为 int类型,所以如果你输入了别的类型的字符,就会导致判断失败,从而不会再次进行循环,然后返回你输入的值的和。

1.4.4 if语句

与大多数语言一样,c++也提供了 if语句来支持条件执行。

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

int main (){
int isnums = 0, nums = 0;

if (std::cin >> isnums) {
int count = 1;

while (std::cin >> nums) {
if (isnums == nums) {
++count;
}else {
std::cout << "The "<< isnums << " occurs "<< count << " times." << std::endl;
isnums = nums;
count = 1;
}
}
std::cout << "The "<< isnums << " occurs "<< count << " times." << std::endl;
}
return 0;
}

类简介

在C++中,我们铜鼓哦定义一个类来定义自己的数据结构。一个类定义了一个类型以及与其关联的一组操作。
类机制就是C++最重要的特性之一。
实际上,C++最初的一个设计焦点上就是能定义使用上像内置类型一样自然的类类型。
为了使用类,我们需要了解三件事情。

  • 类名是什么
  • 它是在哪儿定义的
  • 它支持什么操作

对于我们即将写的书店程序来说,假定我们的类名为Sales_item,头文件 Sales_item.h中已经定义了这个类。

Sales_item类

Sales_item 类的作用是表示一本书的总销售额、售出册数和平均售价。我们现在不关心这些数据如何存储、如何计算。为了使用一个雷,我们不必关心它是如何实现的,只需要知道类对象可以执行什么操作
每个类实际上都定义了一个新的类型,其类型名就是类名。
因此,我们的Sales_item类定义了一个名为Sales_item的类型,与内置类型一样,我们可以定义类类型的变量。
Example:

1
Sales_item item;

此语句是想表达item是一个Sales_item类型的对象,我们通常将 “item是一个Sales_item类型的对象” 简单说成 “一个Sales_item对象”或者更简单的说成“一个Sales_item”。

除了定义Sales_item类型的变量之外呢,我们还可以:

  • 调用一个名为isbn的函数从一个Sales_item对象中提取 ISBN 书号
  • 用输入运算符(>>)和输出运算符(<<)读写Sales_item类型的对象。
  • 用加法运算符(+)将两个Sales_item对象相加,两个对象必须表示同一本书。加法结果是一个新的Sales_item对象,其ISBN与两个运算对象相同,而其总销售额和售出册数则是两个运算对象的对应值之和。
  • 使用复合赋值运算符讲一个Sales_item对象加到另一个对象上。

重要概念:类定义了行为
当你度这些程序时,类Sales_item的作者定义了类对象可以执行的所有动作。即,Sales_item类定义了创建一个Sales_item对象时会发生什么事情。以及对Sales_item对象进行赋值、加法或输入输出运算时会发生什么事情。
一般而言,类的作者决定了类类型对象上可以使用的所有操作。


读写Sales_item
既然即应知道可以对Sales_item对象执行哪些操作,,我们现在就可以便携使用类的程序了。
例如,下面的程序从标准输入读入数据,存入一个Sales_item对象中,然后将Sales_item的内容写回到标准输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "Sales_item.hpp"
#include <iostream>

int main(){
Sales_item book;

// 读入ISBN号、售出的册数以及销售价格。
std::cin >> book;

// 写入ISBN号、售出的册数、总销售额和平均价格。
std::cout << book << std::endl;

return 0;
}

新的include形式:

  • 来自标准库的头文件 用 ( <> )包围头文件名。
  • 来自不属于标准库的头文件,用 ( “” )包围。

Sales_item 对象的加法
下面是一个对象相加的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "addItems.hpp"

#include <iostream>
#include "Sales_item.hpp"

int main(){
Sales_item item1, item2;

// 读取一对交易记录
std::cin >> item1 >> item2;

//打印和
std::cout << item1 + item2 << std::endl;

return 0;

}

初识成员函数

将两个Sales_item对象相加的程序首先应该价差两个对象是否具有相同的ISBN。
方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include "CheckSame.hpp"

#include <iostream>
#include "Sales_item.hpp"

int main(){
Sales_item item1, item2;

// 读取一对交易记录
std::cin >> item1 >> item2;

//首先检查item1和item2是否表示相同的书
if (item1.isbn() == item2.isbn()) {
std::cout << item1 + item2 <<std::endl;
} else {
std::cerr << "Data must refer to same ISBN"
return -1;
}

return 0;

}

这个if语句的检测条件

1
item1.isbn() == item2.isbn()

调用名为isbn的成员函数。成员函数式定义为类的一部分函数,有时也被称为方法(method)
我们通常使用 点运算符(.)来调用方法。通常,此方法必须是当前类类型的。
当我们访问一个成员函数时,通常我们是想调用该函数,我们使用调用运算符( () )来调用一个函数,调用运算符是一顿圆括号,里面放置参数列表(可能为空)。
因为我们现在的成员函数 isbn并不接受参数,因此:

1
item1.isbn()

调用名为 item1 的对象的成员函数 isbn,此函数返回 item1 中保存的 ISBN书号。

自此下面的就不去写了, 感觉本书对此处写的像是磕磕绊绊,一些细节性的东西没有去发现, 可能不适合初学者读吧。
初学者只想知道为什么运行不起来,不会去关心这些跑不起来的东西竟然还要写例子。

第二章 变量和基本类型

数据类型是程序设计的基础,它告诉我们数据的意义以及我们能在数据上执行的操作
数据类型决定了程序中数据和操作的意义。如下所示的语句是一个简单示例:
1
i = i + j;
其含义依赖于 i 和 j 的数据类型。 如果i j 是整形数,那么这条语句执行的就是最普通的加法运算。

基本内置类型

C++定义了一套包括 算数尅性空类型(void) 在内的基本数据类型。
其中算术类型包含了 字符、整形数、布尔值、和浮点数。空类型不对应具体的值,仅用于一些特殊的场合。
例如最常见的是,如果函数不返回任何值的时候使用空类型作为返回类型。

算术类型

算术类型分为两类: 整形(包括字符和布尔类型在内)和浮点型。
算数类型的尺寸(也就是该类型数据所占的比特数)在不同机器上有所差别,所表示的范围也不一样。

布尔类型的取值是 真(true)/假(false)。


带符号类型和无符号类型
除去布尔型和扩展的字符型之外,其他整形可以划分为带符号的无符号的两种。带符号类型可表示正数、负数或0,无符号的类型则仅能表示大禹等于0的值。
类型int、short、long、和long long 都是带符号的,通过在其前面加 unsigened就可以得到无符号类型。
Example:

1
unsigened int


与其他整形不同,字符型被分为了三种: char、sigend char 和 unsigned char。
尽管字符型有三种,但表现形式却只有两种,带符号的和无符号的。具体是哪种由编译器决定。


建议:如何选择类型
和C语言一样,C++的设计准则之一也是尽可能的接近硬件。C++的算数类型必须满足各种硬件特质。

- 当明确知道数值不可能为负时,选用无符号类型。
- 使用int执行整数运算。
- 执行浮点数运算选用double。

类型转换

对象的类型定义了对象能包含的数据和能参与的运算,其中一种运算被大多数类型支持,就是讲对象从一种给定的类型转换为另一种相关类型。
当我们像下面这样发吧一种算术类型的值付给另外一种类型时:

1
2
3
4
5
6
bool b = 42;            //bw为真
int i = b; //i的值为1
i = 3.14; //i的值为3
double pi = i; //pi的值为3.0
unsigend char c = -1; //假设char占8bytes c的值为255
sigend char c2 = 256; //假设char占8bytes c2的值是未定义的。

类型所能表示的值的范围决定了转换的过程。

  • 当我们把一个非布尔类型的算数值赋给布尔类型时,初始值为0则结果为false,否则为true。
  • 当我们把一个布尔值赋给非布尔类型时,初始值为false则结果为0,否则为1.
  • 当我们把一个浮点数赋给整数类型时,进行了近似处理。结果值将仅保留浮点数中小数点之前的部分。
  • 当我们把一个整数值赋给浮点类型时,小数部分记为0.如果该整数所占的空间超过了浮点类型的容量,精度有可能损失。
  • 当我们赋给带符号类型一个超出它表示范围的值时,结果是未定义的,此时,程序有可能继续工作,可能崩溃,也可能生成垃圾数据。
  • 当我们赋给无符号类型一个超出它表示范围的值时,结果是初始值对无符号类型表示树枝总数取模后的余数。

建议:避免无法预知和依赖于实现环境的行为。
    无法预知的行为源于编译器无需检测的错误。即使代码编译通过了,如果程序执行了一条未定义的表达式,仍有可能产生错误。
    不幸的是,在某些情况或某些编译器下,含有无法预知行为的程序也能正确执行。但是我们却无法保证同样一个程序在别的编译器下能正常工作。甚至已经编译通过的代码再次执行也可能会出错。
    程序也应尽量避免依赖于实现环境的行为。

字面值常量

一个形如42的值被称作字面值常量,这样的值一望而知。
每个字面值常量都对应一种数据类型,字面值常来你的形式和值决定了他的数据类型。

整型和浮点型字面值

我们可以将整型字面值写作十进制数、八进制数或十六进制数的形式。以0开头的整数代表八进制数,以0x或者0X开头的代表十六进制。

整型字面值具体的数据类型由它的值和符号决定,默认情况下,十进制字面值是带符号数,八进制和十六进制字面值极可能带符号也可能是无符号。
浮点型字面值是一个double。

字符和字符串字面值

由单引号括起来的一个字符成为char型字面值,双引号括起来的另个或多个字符则构成字符串型的字面值
‘a’ – 字符型字面值
“abc” – 字符串型字面值

转义序列

有两类字符程序员不能直接使用,一类是 不可打印的字符,如退格或其他控制字符,因为它们没有可视的图符;另一类是在C++语言中有特殊含义的字符(单引号,双引号,问号,反斜线)。在这些情况下需要用到转义序列,转义序列均以反斜线作为开始。


换行符 \n 横向制表符 \t 报警符 \a
纵向制表符 \v 退格符 \b 双引号 \”
反斜线 \\ 问号 \? 单引号 \’
回车符 \r 进纸符 \f


在程序中,上述转义序列被当做一个字符使用。

布尔字面值和指针字面值

true和false是布尔类型的字面值。
bool test = false;
nullptr 是指针字面值。

变量

变量提供一个具体名字,可供程序操作的存储空间,c++中的每个变量都有其数据类型。数据类型决定着变量所占内存空间的大小和布局方式。该空间能存储的值的范围以及变量能参与的运算,
对C++程序员来说,“变量”和“对象”一般可以互换使用。

变量定义

变量定义的基本形式是:首先是类型说明符,随后紧跟由一个或多个变量名组成的列表,其中变量名以逗号分隔,最后1️以分号结束。
列表中每个便来匿名的类型都由类型说明敷指定,定义时还可以为一个或多个变量赋初值。

1
2
3
4
int sum - 0, value // sum。value 都是int  sum初值为0;
Sales_item item; //item的类型是Sales_item。
//string 是一种库类型,表示一个可变长的字符序列。
std::string book (0-123-45678-X);

何为对象:
C++程序员们在很多场合都会使用对象这个名词。通常情况下,对象是指一块能存储数据并具有某种类型的空间。
一些人仅在与类有关的场景下才使用“对象”这个词。另一些人则已把命名的对象和未命名的对象区分开来,其中对象指能被程序修改的数据,而指制度的数据。


初始值

当对象在创建时获得了一个特定的值,我们说这个对象被初始化了。用于初始化便拉近的值可以使任意复杂的表达式。
当一次定义了两个或多个变量时,对象的名字随着定义也就马上可以使用了。因此在同一条定义语句中,可以用箱定义跌变量值去初始化后定义的其他变量。

Example:

1
2
3
4
//用price的值初始化discount
double price = 109.99, discount = price * 0.16;
//调用函数applyDiscount并返回值用来初始化salePrice
double salePrice = applyDiscount(price, discount);

列表初始化

C++定义了初始化的好几种不同形式,这也是初始化问题复杂性的一个体现。
例如,要想定义一个名为units_soid的int变量并初始化为0,以下的四条语句都可以做到:

1
2
3
4
int units_soid = 0;
int units_soid = {0};
int units_soid(0);
int units_soid{0};

作为C++新标准的一部分,永花括号来初始化变量得到了全面应用,在此之前仅在某些受限的场合下使用。
这种初始化的形式被称为列表初始化。

当用于内置类型的变量时,这种初始化形式有一个重要特点:如果我们使用列表初始化切初始值存在丢失信息的风险,则编译器会报错:

1
2
3
long double id = 3.1415926536;
int a{id}, b = {id}; // 错误:转换未执行,因为存在信息丢失的危险。
int c(id), d = id; // 正确:转换执行,且确实丢失了部分值。

默认初始化

如果定义变量时没有指定初始值。则变量就会被默认初始化,此时变量被赋予了‘默认值’,默认值到底是什么由变量类型决定。
同时顶一边拉怪in的位置也会对此有影响。

如果是内置类型的变量未被显示初始化,它的值由定义的位置决定。
定义于任何函数体之外的变量被初始化为0.
一种例外情况是,定义在函数体内部的内置类型白能量将不被初始化
一个未被初始化的内置类型变量的值是未定义的。如果试图拷贝或以其他形式访问此类值将引发错误。

每个类格子决定其初始化对象的方式。而且,是否允许不精初始化就定义对象也由类型自己决定,如果类允许这种行为,它将决定对象的初始值到底是什么。

绝大多数类都至此无需显示初始化而定义对象,这样的类提供了一个合适的默认值。例如,string类规定如果没有指定初值则生成一个空串:

1
2
std::string empty;  // 默认值为空 ""
Sales_items item; // 被默认初始化的Sales_item对象。

一些类要求每个对象都显示初始化,此时如果创建了一个该类的对象而未对其做明确的初始化操作,将引发错误。


定义于函数体内的内置类型的对象如果没有初始化,则其值未定义。类的对象如果没有显示的初始化,则其值由类决定。


执行默认初始化时,内置类型的值是未定义的.这句话是有前提的,前提就是这个内置类型在哪申请的空间。

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
#include <iostream>
using namespace std;

//,在静态存储区申请,所以初始化为0
int a;

//这个叫做值得初始化,3作为初始值
int a_1 = 3;

int main() {
//这个叫做有初始化值
int k = 5;

//这个不是初始化,叫赋值
k = 8;

//b也是内置类型,但是他在函数体申请,所以是在栈申请的空间,所以值未定义
int b;

//new出来的空间都是在堆申请的,有操作系统自动分配可用空间,所以不会初始化
int *p = new int;

//static申明的成员是存储在静态存储空间的,所以会初始化为0
static c;

return 0;

}

提示:未初始化变量引发运行时故障。


未初始化的变量含有一个不确定的值,使用未初始化变量的值是一种错误的变成行为并且很难调试。尽管大多数编译器都能对一部分使用未初始化变量的行为提出警告,但严格来说编译器并未被要求检查此类错误。
使用未初始化的便拉近将带来无法预计的后果。有时我们足够幸运,一访问此类对象程序就崩溃并报错,此时只要找到崩溃的位置就很容易发现变量没有初始化的问题。
另外一些时候,程序会一直执行完并产生错误的结果。更糟糕的错误是,程序结果时对时错,无法把握。而且往无关的位置添加代码还会导致我们误以为程序对了,其实结果依旧有错。



梳理一下堆栈

  • 定义:
    • 栈区(stack)— 由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
    • 堆区(heap) — 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。
  • 区别和联系:
    • 申请方式
      • 堆是由程序员自己申请并指明大小,在c中malloc函数 如p = (char *)malloc(10);
      • 栈由系统自动分配,如声明在函数中一个局部变量 int b; 系统自动在栈中为b开辟空间
    • 申请后系统的响应
      • 栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。
      • 堆:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历改链表。寻找第一个空间大于所申请的堆节点,然后将该节点从空闲结点链表中删除,并将该结点的空间分配给程序。另外,对于大多数系统,会在这块内存空间的首地址处记录本次分配的大小,这样,代码中的delete语句才能正确的释放此内存空间。另外,由于找到的堆结点的大小不一定正海等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。
    • 申请大小的限制
      • 栈:在win下,栈是像低地址扩展的数据结构,是一块连续的内存区域。这句话的意思是栈顶的地址和站的最大容量是系统预先规定好的,在win下,栈的大小是2M,如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获取的空间较小。
      • 堆:堆是像高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不了徐的,而链表的遍历方向是由低到高地址。堆的大小受限于计算机系统中的有小雨你内存。所以堆的空间比较灵活,也比较大。
    • 申请效率比较
      • 栈:由系统自动分配,速度较快。但程序员无法控制。
      • 堆:由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便。

变量声明和定义的关系

为了允许把程序拆分成多个逻辑部分来编写,C++语言支持分离式编译机制,该机制允许将程序分割为若干个文件,每个文件可被独立编译
为了支持分离式编译,C++语言将声明和定义区分开来。
声明使得名字为程序所知,一个文件如果想使用别处定义的名字则必须包含对那个名字的声明。而定义负责创建与名字关联的实体。
定义还会申请存储空间,也可能会为变量赋一个初始值。
如果想声明一个变量而非定义它,就在变量名前添加关键字 extern, 而且不要显式的初始化变量:

1
2
extern int i;  //声明而非定义。
int j; //声明并定义。

任何包含了显式初始化的声明即成为定义。我们能给出 extern 关键字标记的拜年啦给你赋一个初始值,但是这么做就抵消了extern的作用。 extern语句如果包含初始值就不再是声明,而变成定义了:

1
extern int i = 19;  //定义。

在函数体内部,如果试图初始化一个由extern关键字修饰的变量,将引发错误。


变量能且只能被定义一次,但是可以被多次声明。


概念:静态类型


C++是一种静态类型语言,其含义是在编译节点检查类型。其中,检查类型的过程成为类型检查。
我们已经知道,对象的类型决定了对象所能参与的运算。在C++语言中,编译器负责检查数据类型是否支持要执行的运算,如果试图执行类型不支持的运算,编译器将报错并且不会生成可执行文件。
程序越复杂,静态类型检查越有助于发现问题,然而,前提是编译器必须知道每一个实体对象的类型。


标识符

C++的标识符由字母、数字、下划线组成,其中必须以字母或下划线开头。标识符的长度没有限制,但是对大小写敏感。
C++语言保留了一些名字供语言本身使用,这些名字不能被用作标识符。
同时,C++也为标准库保留了一些名字。用户自定义的标识符中不能连续出现两个下划线,也不能以下划线紧连大写字母开头。此外,定义在函数体外的标识符不能以下划线开头。

变量命名规范

变量命名有许多约定俗成的规范,下面的这些规范能有效提高程序的可读性:

  • 标识符要能体现实际含义。
  • 变啦滚名一般用小写字母,如index,不要使用Index或 INDEX。
  • 用户自定义的类名一般以大写字母开头,如 Sale_item。
  • 如果标识符由多个单词组成,则单词间应用明显区分。

名字的作用域

不论是在圣墟的什么位置,使用到的每个名字都会执行一个特定的实体:变量、函数、类型等,同一个名字如果出现在程序的不同位置,也可以执行的是不同实体。

作用域是程序的一部分,在其中名字有其特定的含义。C++语言中大多数组用于都以花括号分离。
同一个名字在不同的作用域中可能指向不同的实体。名字的有效区域始于名字的声明语句,以声明语句所在的作用域末端为结束。

建议:当使用变量的时候再去定义


一般来说,在对象第一次被使用的地方附近定义它是一种好的选择,因为这样做有利于更容易的找到便来那个的定义。
更重要的是,当变量的定义与它第一次被使用的地方很近时,我们也会赋给它一个比较合理的初始值。


嵌套的作用域

作用域能彼此包含,被包含的作用域称为内层作用域,包含着别的作用域称为外部作用域。
作用域中一旦声明了某个名字,它所嵌套的所有作用域中都能访问该名字。同时,允许在内层作用域中重新定义外层作用域已有的名字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
*函数内部不宜定义与全局变量同名的新变量
*/
#include <iostream>

//全局变量
int reused = 42;
int main(int argc, char *argv[]){
//块变量
int unique = 0;
//输出 #1:使用全局变量reused
std::cout << reused << " " << unique << std::endl;

//覆盖全局变量reused
int reused = 0;
// 输出 #2:使用局部变量reused
std::cout << ::reused << " " << unique << std::endl;

//输出 #3:显式的访问全局变量reused,
std::cout << ::reused << " " <<unique << std::endl;
}

解释:
输出#1:出现杂我jububianlaignreused定义之前,因此这条语句使用全局作用域中定义的名字reused,输出42 0.
输出#2:发生咋已局部变量reused定义之后,此时局部变量reused正在作用域内,因此第二条输出语句使用的是局部变量reused而非全局变量,输出0 0.
输出#3:使用域操作符 :: 来覆盖默认的作用域规则,因为全局作用域本身并没有名字,所以当作用域左侧为空时,向全局作用域发憷请求获取作用域操作符右侧名字对应的变量。结果是,第三条输出语句使用全局变量reused,输出42 0

建议


如果函数有可能用到某全局变量,则不宜再定义一个同名的局部变量。


复合类型

复合类型 是指基于其他类型定义的类型。
C++语言有几种复合类型,下面介绍 引用和指针。
与我们已经掌握的变量声明相比,定义复合类型的便拉近要复杂很多。
之前 提到,一条简单的声明语句由一个数据类型和紧随其后的一个变量名列表组成。其实更通用的描述是,一条声明语句由一个基本数据类型和紧随其后的一个声明符列表组成。每个声明符命名了一个变量并指定该变量为与基本数据类型有关的某种类型。

引用


C++11中新增了一种引用:所谓的“右值引用”,这种引用主要用于内置类。严格来说,当我们使用术语“引用”时,指的其实是“左值引用”


引用 为对象起了另外一个名字,引用类型引用另外一种类型。
通过将声明写成 &d 的形式来定义引用类型,其中d是声明的变量名。

1
2
3
int ival = 1024;
int &refival = ival; //refval指向ival(ival的另一个名字)
int &refval2; //报错:引用必须初始化

一般在初始化变量时,初始值会被拷贝到新建的对象中,然而定义引用时,程序会把引用和它的初始值绑定在一起,而不是将初始值拷贝给引用。
一旦初始化完成,引用将和他的初始值对象一直绑顶在一起,因为无法令引用重新把那个顶到另外一个对象,因此引用必须初始化。

引用即别名


引用并非对象,相反的,它只是为一个已经存在的对象所起的另外一个名字。


为引用赋值,实际上是把值付给了与引用绑定的对象。获取引用的值,实际上是获取了与引用绑定的对象的值。同理,以引用作为初始值,实际上是以与引用绑定的对象作为初始值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

using namespace std;
int main(int argc, char *argv[]) {

int a = 10;
int b = 20;
int &ra = a;
ra= b;

cout << ra << endl; // 输出10
cout << a << endl; // 输出20

return 0;
}

因为引用本身不是一个对象,所以不能定义引用的引用。

引用的定义

允许在一条语句中定义多个引用,其中每个引用标识符都必须以符号&开头:

1
2
3
4
5
6
7
int i = 1024, i2 = 2048;    // 都是int
int &r = i, r2 = i2; // r是一个引用,与i绑在一起,r2是int
int i3 = 1024m &ri = i3; // i3是int,ri是一个引用,与i3绑定在一起
int &r3 = i3, &r4 = i2; // r3和r4都是引用

int &refNum = 10; //错误
//引用只能绑定在对象上,而不能与字面值或者某个表达式的计算结果绑定在一起。

指针

指针是指向另外一种类型的复合类型。与引用类似,指针也实现了对其他对象的间接访问。然而指针与引用相比又有很多不同点。
其一,指针本身就是一个对象,允许对指针赋值和拷贝,而且咋指针的生命周期内它可以先后指向几个不同的对象。
其二,指针无需在定义时赋初值。和其他内置类型一样,在快块作用域内定义的指针如果没有被初始化,也将又有一个不确定的值。


指针通常难以理解,即使有经验的程序员也常常因为调试指针引发的错误而烦恼


定义指针类型的方法将声明符写成 x(变量) 的形式。
如果在一条语句中定义了几个指针变量,每个变量都要有

1
2
int *pi1, *pi2, *pi3;   //都是指向int类型对象的指针。
double *pd1, pd2; //pd1是指向double类型对象的指针

获取对象的地址

指针存放某个对象的地址,要想获取该地址,需要使用取地址符(&)

1
2
int ival = 42;
int *pval = &ival; //pval存放变量ival的地址,或者说pval是指向val变量的指针。

第二条语句吧pval定义为一个指向int的指针,随后初始化pval另其指向名为ival的int对象。
因为引用不是对象,没有实际地址,所以不能定义指向引用的指针。
和引用一样,指针只能绑定在对象上,而不能与字面值或者某个表达式的计算结果绑定在一起。

1
2
3
4
5
6
double dval;
double *pd = &dval; //正确:初始值是double型对象的地址。
double *pd2 = pd; //正确:初始值是指向double对象的指针

int *pi = pd; //错误:指针类型和pd类型不匹配
pi = &dval; //错误:视图把double型对象的地址赋给int型指针

因为在声明语句中指针的类型实际上被用于指定它所指向对象的类型,所以二者必须匹配。如果指针指向了一个其他类型的对象,对该对象的操作将发生错误。

指针值

指针的值(即地址)应属于下列四中状态之一:

  • 指向一个对象
  • 指向紧邻对象所占空间的下一个位置
  • 空指针,意味着指针没有指向任何对象
  • 无效指针,也就是上述情况之外的其他值,

试图拷贝火以其他方式访问无效的指针豆浆引发错误。
编译器并不负责检查此类错误。,这一点和试图使用未经初始化的变量是一样的。访问无效指针的后果无法预计,因此程序员必须清楚任意给定的指针是否有效。

尽管第二种和第三种形式的指针是有效的,蛋其使用同样受到限制。显然这些指针没有指向任何具体对象,所以试图访问此类指针(假定的)对象的行为不被允许。如果这样做了,后果也无法预计。

利用指针访问对象

如果指针指向了一个对象,则允许使用解引用符(*)来访问对象。

1
2
3
int ival - 42;      
int *p = &ival; //p存放着变量ival的地址,或者说p是指向变量ival的指针
cout <<*p<<endl; //由符号*得到指针p所指的对象,输出42。

对指针解引用会得出所指的对象,因此如果给解引用的结果赋值,实际上也就是给指针所指的对象赋值。
解引用操作仅适用于那些确实指向了某个对象的有效指针。

关键概念:某些符号有多重含义


像 & * 这样的符号,技能用做表达式里的运算符,也能作为声明的一部分出现,符号的上下文决定了符号的意义:

1
2
3
4
5
int i = 42;
int &r = i; //&紧随类型名出现,因此是声明的一部分,所以是引用
int *p; //*紧随类型名出现,因此是声明的一部分,所以是指针
p = &i; //&出现在表达式中,是一个取地址符
int &r2 = *p; //&是声明的一部分,*是一个解引用符。

在声明语句中, & 和 * 用于组成复合类型;在表达式中,他们的角色又转变成运算符。在不同场景下出现的虽然是同一个符号,但是由于含义截然不同,所以我们完全可以吧它当做不同的符号来看待。


空指针

空指针不指向任何对象,在试图使用一个指针之前,代码可以首先检查它是否为空,以下列出几个生成空指针的方法:

1
2
3
int *p1 = nullptr;
int *p2 = 0;
itn *p3 = NULL;

得到空指针最直接的办法就是采用字面值 nullptr来初始化指针,这也是C++11新标准刚刚引入的一种方法。
nullptr是一种特殊类型的字面值,它可以被转换成任意其他的指针类型。
过去的程序还会用到一个名为NULL预处理变量来给指针赋值,这个变量在头文件catdlib中定义,它的值就是0。
预处理变量不属于命名空间std,它由预处理器负责管理,因此我们可以直接使用预处理白能量儿无需在前面加域操作符
当用到一个预处理变量时,预处理器会自动的将它替换为实际值,因此用NULL初始化指针和0初始化指针是一样的。在新标准下,现在的C++程序最好使用nullptr,同事尽量避免使用NULL。

把int变量直接赋给指针是错误的操作,即使int变量的值签好等于0也不行。

1
2
int zero = 0;
ip = zero;

建议:初始化所有指针


使用未经初始化的指针是引发运行时错误的一大原因。
和其他变量一样,访问未经初始化的指针所引发的后果也是无法预计的。
通常这一行为将造成程序崩溃,而且一旦崩溃,要想定位到出错位置讲师特别棘手的问题。
在大多数编译器环境下,如果使用了未经初始化的指针,则该指针所占内存空间的当前内容将会被看做一个地址值。
访问该指针们相当于去访问一个本不存在位置上的本不存在的对象。如果指针所占内存空间中恰好有内容,而这些内容又恰好被当做了某个地址,我们就很难分清它是合法的还是非法的了。
因此建议初始化所有的指针,并且在可能的情况下,尽量等定义了对象之后再定义指向他的指针,如果实在步行出指针应该指向何处,就初始化为nullptr,这样程序就能检测并知道它有没有指向任何具体的对象了。


赋值和指针

指针和引用都能提供对其他对象的间接访问,然而在具体实现细节上二者有很大不同,其中最重要的一点就是引用本身并非是一个对象。一旦定义了引用,就无法另其再绑定到另外的对象,之后每次使用这个引用都是访问它最初绑定的那个对象。
指针和它存放的地址之间就没有这种限制了。和其他任何变量(只要不是引用)一样,给指针赋值就是令它存放一个新的地址,从而指向一个新的对象:

1
2
3
4
5
6
7
int i = 42;
int *pi = 0; //pi被初始化,没有指向任何对象
int *pi2 = &i; //pi2被初始化并指向了i的地址。
int *pi3; //pi3被定义,但未被初始化,所以pi3的值不确定。

pi3 = pi2; //pi3和pi2指向同一个对象i
pi2 = 0; //pi2又被初始化,并不指向任何对象

其他指针操作

只要指针拥有一个合法值,就能将它用在条件表达式中。和采用算数值作为条件遵循的规则类似,如果指针的值是0;则条件取false。

1
2
3
4
5
6
7
8
9
10
int ival = 1024;    //
int *pi = 0; // pi是一个空指针。
int *pi2 = &ival; // pi2存这ival的地址、
if(pi){ // pi的值是0,因此条件为false
//...
}

if(pi2){ // pi2的值是1024,因此条件为true
//...
}

结论:任何非0指针对应的条件都是true。
对于两个类型的合法指针,可以用相等操作符(==)或不相等操作符(!=)来比较他们,比较的结果是布尔类型。如果两个指针村法规的地址值相同,则他们相等,反之则不等。
这里两个指针存放的地址值相同(两个指针相等),有三种可能,它们都为空,都指向同一个对象,或者都指向了同一个对象的下一地址。
需要注意的是,一个指针指向某对象,同时另一个指针指向另外对象的下一地址,此时也有可能出现这两个指针值相同的情况,即指针相等。
因为上述操作要用到指针的值,所以不论是作为条件出现还是参与比较运算,都必须使用合法指针,使用非法指针作为条件或进行比较都会引发不可预计的后果。

void* 指针

void* 是一种特殊的指针类型,可用于存放任意对象的地址。一个void*的指针存放着一个地址,这一点和其他指针类似。不同的是,我们对该地址中到底是个什么类型的对象并不了解:

1
double obj = 3.14, *pd = &obj;

利用viod*指针能做的事情比较有限:拿它和别的指针比较、作为函数的输入或输出,或者赋给另一个void*指针。不能直接操作void*所指的对象,因为我们并不知道这个对象的类型,也就无法确定能在这个对象上进行什么操作。
概括来讲,以void*的视角来看内存空间也就仅仅是内存空间,没办法访问内存空间中所存的对象。

理解复合类型的声明

变量的定义包括一个基本数据类型和一组声明符。子啊同一条定义语句中,虽然基本数据类型只有一个,但是声明符的形式却可以不同,也就是说,一条定义语句可能定义出不同类型的变量。

1
2
// i是一个int型的整数,p是一个int型的指针,r是一个int型的引用。
int i = 1024, *p - &i, &r = i;


很多程序员迷惑于基本数据类型和类型修饰符的关系,其实后者不过是声明符的一部分。


定义多个变量

经常有一种观点会误以为,在定义语句中,类型修饰符(*或&)作用于本次定义的全部变量。造成这种错误看法的原因有很多,其中之一是我们可以把空格写在类型修饰符和变量名中间

1
int* p; // 合法但是容易产生误导。

我们说这种写法可能产生误导是因为int放在一起好像是这条语句中所有变量共同的类型一样,其实恰恰相反,基本数据类型是int而非int\。 *仅仅是修饰了p而已,对该声明语句中的其它变量,它并不产生任何作用:

1
2
// p1是指向int类型的指针,p2是int
int* p1, p2;

涉及指针或引用的声明,一般有两种写法,第一种把修饰符和变量标识符写在一起:

1
int *p1, *p2; // p1 和 p2都是指向int的指针。

这种形式着重强调变量具有的复合类型。第二种把修饰符和类型名卸载一起,并且每条语句只定义一个变量。

1
2
int* p1; // p1 是指向int的指针。
int* p2; // p2 是指向int的指针。

这种形式则强调了本次声明定义了一种复合类型。


上述两种定义指针或引用的不同方法没有对错之分,关键是选择并坚持其中的一种写法,不要总是变来变去。


指向指针的指针

一般来说,盛明富中修饰符的个数并没有显示。当有多个修饰符连写在一起时,按照其逻辑关系详加解释即可。
以指针为例,指针是内存中的对象,像其他对象一样也有自己的地址,因此允许把指针的地址再放到另一个指针中。

通过*的个数可以区分指针的级别,也就是说,**表示指向指针的指针,***表示指向指针的指针的指针,以此类推。

1
2
3
int ival = 1024;
int *pi = &ival; // p1 指向一个int类型的数。
int **ppi = &p1; // p2 指向一个int类型的指针。

此处pi是指向int型数的指针,而ppi是指向int型指针的指针。
解引用int型指针会得到一个int型的数,同样,解引用指向指针的指针会得到一个指针。此时为了访问最原始的对象,需要对指针的指针 做两次解引用。

指向指针的引用

引用本身不是一个对象,因此不能定义指向引用的指针。但指针是对象,所以存在对指针的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main(int argc, char *argv[]) {

int i = 42;

// p是一个int型指针。
int *p;

//r是对指针p的引用
int *&r = p;

// r 引用 i 的指针,因此给 r 赋值 &i 就是令 p 指向 i
r = &i;

//解引用 r 得到 i,也就是p指向的对象,将i的值改为0。
*r = 0;

return 0;
}

要理解 r 的类型到底是什么,最简单的办法就是从右向左阅读 r 的定义。
离变量名最近的符号 & 对变量的类型有最直接的音箱,因此r是一个引用。声明符的其余部分用以确定r引用的类型是什么,此例中的符号 * 说明 r 引用的是一个指针。最后,声明的基本数据类型部分指出 r 引用的是一个int指针。

const限定符

有时我们希望定义这样一种变量,它的值不能被改变。例如,用一个变量来表示缓冲区的大小。使用变量的好处是当我们觉得缓冲区大小不再合适时,很容易对其进行调整。另一方面,也应随时警惕防止程序一不小心改变了这个值。为了满足这一要求,可以用关键字const对变量的类型加以限定。

1
const int bufSize = 512; // 输入缓冲区大小

这样就把bufSize定义成了一个常亮。任何试图为bufSize赋值的行为都将引发错误。

1
bufSize = 512; // 错误:试图像const对象写入。

因为const对象一旦创建后其值就不能再改变,所以const对象必须初始化。一如既往,初始值可以使任意复杂的表达式。

1
2
3
const int i = getsize();  // 正确:运行时初始化。
const int j = 42; // 正确:编译时初始化。
const int k; // 错误:k是一个未经初始化的常量。

初始化和const

正如之前反复提到的,对象的类型决定了其上的操作。与非const类型所能参与的操作相比,const类型的对象能完成其中大部分,但也不是所有的操作都适合。
主要的限制就是只能在const类型的对象上执行不改变其内容的操作。例如,const int 和普通的 int 一样都能参与算术运算,也都能转换成一个布尔值。

在不改变const对象的操作中还有一种是初始化,如果利用一个对象去初始化另外一个对象,则它们是不是const都无关紧要。

1
2
3
int i = 42;
const int ci = i;
int j = ci;

尽管ci是整型常量,但无论如何 ci 中的值还是一个整型数。ci 的常量特征仅仅在执行改变ci的操作时才会发挥作用。当用 ci 去初始化j时,根本无需在意ci是不是一个常量。拷贝一个对象的值并不会改变它,一旦拷贝完成,新的对象就和原来的对象没什么关系了。

默认状态下,const对象仅在文件内有效

当以编译时初始化的方式定义一个const对象时,就如对bufSize的定义一样:

1
const int bufSize = 512; // 输入缓冲区大小

编译器将在编译的过程中把用到该变量的地方都替换成相对应的值。也就是说,编译器会找到代码中所有用到 bufSize 的地方,然后用 512 替换。

为了执行上述替换,编译器必须知道变量的初始值。如果程序包含多个文件,则每个用了const对象的文件都必须得能访问它的初始值才行,要做到这一点,就必须在每个用到变量的的文件中都有对它的定义。为了支持这一用法,同时避免对同一个变量的抽工夫定义,默认情况徐昂西啊,const对象被设定为仅在文件内有效。但你给多个文件中出现了同名的const变量时,其实等同于在不同文件中分别定义了独立的变量。

某些时候有这样一种const变量,它的初始值不是一个常量表达式,但又确实有必要在文件间共享。这种情况下,我们不希望编译器为每个文件分别生成独立的变量。
相反,我们想让这类const对象像其他(非常量)一样工作。也就是说,只在一个文件中定义const,而在其他多个文件中声明并使用它。

而解决方法非常简单,对于const变量不管是声明还是定义都添加extern关键字,这样只需要定义一次。

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
//  FILE1: str.h
#ifndef STR_H
#define STR_H

#include <iostream>

// 在头文件中可以选择将其初始化。
// extern const std::string str = "AB";

// 也可以选择不初始化,到引用头文件的地方进行初始化。
extern const std::string str;

class str {
};

#endif //STR_H


// FILE2: str.cpp
#include "str.h"

extern const std::string str = "ABCDE";

int main(int argc, char *argv[]) {
std::cout << "abc"<< ceshi << std::endl;
// 输出字符为 AB

std::cout << "abc"<< ceshi << std::endl;
// 输出字符为 AB
return 0;
}

因为str是一个常量,所以必须用 extern关键字修饰。

const的作用

可以把引用绑定到const对象上,就像绑定到其他对象上一样,我们称之为对常量的引用。与普通引用不同的是,对常量的引用不能被用作修改它所绑定的对象。

1
2
3
4
const int c1 = 1024;
const int &r1 = c1 // 引用及其对应的对象都应该是常量
r1 = 42; // 错误, r1是常量。
int &r2 = c1 // 错误,非常量不能引用常量。

因为不允许直接为c1赋值,当然也就不能通过引用取改变c1,因此,对r2的初始化是错误的。假设改初始化合法,则可以通过r2来改变它引用对象的值,这显然是不正确的。

const属性在C++中是内部链接属性。在其他文件访问无法访问到。

1
2
3
4
5
6
7
8
9
10
FILE1:
int a = 100;

FILE2:
extern int a;

// 无法输出。链接时链接不到该属性。
cout << a << endl;

解决方法:在C++中,全局const变量没有隐式的加入extern关键字。只需要加入extern关键字即可。


C++程序员经常把词组 “对const的引用” 简称为 “常量引用”,这一简称还是挺靠谱的,不过前提是你得时刻记着这就是个简称而已。
严格来说,并不存在常量引用。因为引用不是一个对象,所以我们没法让引用本身恒定不变。事实上,由于C++语言并不允许随意改变引用所绑定的对象,所以从这层意义上理解所有的引用又都算是常量。引用的对象是常量还是非常量可以决定其所能参与的操作,却无论如何都不会影响到引用和对象的绑定关系本身。


初始化和对const的引用

上一节提到,引用的类型必须与其所引用对象的类型一致,但是有两个例外。
第一种例外情况是在初始化常量引用时允许用任意表达式作为初始值,只要该表达式的结果能转换成引用的类型即可。尤其,允许为一个常量引用绑定非常量的对象,字面值,甚至是一个表达式。

1
2
3
4
5
int i = 42;
const int &r1 = i; // 允许将const引用绑定到普通int对象上。
const int &r2 = 42; // 正确。r1是一个常量引用。
const int &r3 = r1 * 2; // 正确。r2是一个常量引用
int &r4 = r1 * 2; // 错误。r4是一个普通的非常量引用。非const引用的右值不能是表达式

要想理解这种例外情况的原因,最简单的办法就是弄清楚当一个常量引用被绑定到另外一种类型上时到底发生了什么:

1
2
double dval = 3.14;
const int &ri = dval;

此处ri引用了一个int型的数,对ri的操作应该是整数运算,但dval却是一个双精度浮点而非整数。因此为了缺包让ri的绑定一个整数,编译器吧上述代码变成了如下形式:

1
2
const int temp = dval;
const int &ri = temp;

在这种情况下,ri做了一个临时量对象。所谓临时量对象就是当编译器需要一个空间来暂存表达式的求职结果时,临时常见的一个未命名的对象。C++程序员们常常把临时量对象简称为临时量。

接下来探讨当ri不是常量时,如果执行了类似于上面的初始化过程将带来什么样的后果。如果ri不是常量,就允许对ri赋值,这样就会改变ri所引用对象的值。注意,此时绑定的对象是一个临时变量而非dval。程序员既然让ri引用dval,就肯定想通过ri改变dval的值,否则干什么要给ri赋值呢?如此看来,既然大家基本上不会想着把引用绑定到临时量上,C++语言也就把这种行为归为非法。

对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
29

#include <iostream>
int main(int argc, char *argv[]) {

int i = 42;
int &r1 = i; // 引用r1 绑定对象i。
const int &r2 = i; // 引用r2也绑定了对象i,但因为r2是const,所以不允许通过其修改i的值

std::cout << r1 << std::endl;
std::cout << r2 << std::endl;

// 通过更改源的方式来改变const引用的值
r1 = 0; // r1 不是常量,i的值被修改为0;
// r2 = 0; // 错误:r2 是常量引用。

std::cout << r1 << std::endl;
std::cout << r2 << std::endl;

// const常量不能更改
r1 = 15;
const int num = i;;
std::cout << num << std::endl; // 输出15

i = 20;
r1 = 20;
std::cout << num << std::endl; // 输出15,const常量不可更改。

return 0;
}

r2 绑定整数i是合法的,然而,不允许通过r2修改i的值。尽管如此,i的值仍然允许通过其他途径修改,既可以直接给i复制,也可与通过像r1一样绑定到i的其他引用来修改。

指针和const

与引用一样,也可以令指针指向常量或非常量。类似于常量引用。,指向敞亮的指针不能用不改变其所指对象打的值。要想存放敞亮的对象的地址,只能使用指向常量的指针:

1
2
3
4
const double pi = 3.14;     // pi是个常量,他的值不能改变。
double *ptr = &pi; // 错误:ptr是一个普通指针。
const double *cptr = &pi; // 正确:cptr可一直想一个双精度常量
*cptr = 42; // 错误:不能给*cptr赋值。

2.3.2节提到,只针对的类型必须与其所指对象的类型一直,但是有两个例外、第一种类外情况是允许另一个纸箱厂凉的指针指向一个非常量对象:

1
2
double dval = 3.14;     // dval是一个双精度浮点数,它的值可以改变。
cptr = &dval; // 正确:但是不能通过cptr改变dval的值。

和常量引用一样,指向敞亮的指针也没有规定其所指的对象必须是一个常量。所谓指向敞亮的指针仅仅要求不能通过该指针改变的对象的值,而没有规定那个对象的值不能通过其他途径改变。


试试这样想:所谓指向敞亮的指针或引用,不过是指针或引用“自以为是”罢了。它们觉得自己指向了常量,所以自觉地不去改变所指向对象的值。


const指针

指针是对象而引用不是,因此就像其他对象类型一样,允许把指针本身定位常量。常量指针必须初始化,而且一旦初始化完成,则它的值(也就是存放在指针中的那个地址)就不能再改变了。把*放在const关键字之前用以说明指针是一个常量,这样的书写形式隐含着一层意味,即不变的是指针本身的值而非指向的那个值:

1
2
3
4
int errNumb = 0;
int*const curErr = &errNumb // curErr将一直直系那个errNumb
const double pi = 3.14159;
const double *const pip = &pi; // pip是一个指向常量对象的常量指针

如同2.3.2节所讲的,要想弄清楚这些生命的含义,最行之有效的办法是从右向左阅读。此例中,离curErr最近的符号是 const,意味着curErr 本身是一个常量对象,对象的了O型由声明福的其余部分决定。声明福中的下一个符号是*,意思是curErr是一个常量指针。最后,该声明语句的基本数据类型部分确定了常量指针指向的是一个int对象。与之相似,我们也能推断出,pip是一个常量指针,它指向的对象是一个双精度浮点型常量。

指针本身是一个常量并不意味着不能通过指针修改其所指对象的值,能否这样做完全依赖于所指对象的类型,例如,pip是一个指向常量的常量指针,则不论是pip所指的对象值还是pip自己存储的那个地址都不能改变。相反的,curErr指向的是一个一般非常量整数,那么就完全可以用curErr去修改errNumb的值:

1
2
3
4
5
6
*pip = 2.72// 错误:pip是一个指向常量的指针

if(*curErr){ // 如果curErr所指的对象(也就是errNumb)的值不为0
errorHandler(); //
*curErr = 0; // 正确:把curErr所指的对象的值重置
}

顶层const

如前所述,指针本身是一个对象,它又可以指向另外一个对象。因此,指针本身是不是常量以及指针所指的是不是一个常量就是两个相互独立的问题。
用名词顶层const表示指针本身是个常量,而用名词底层const表示指针所指的对象是一个常量。
更一般的,顶层const可以表示任意的对象是常量,这一点对任何数据类型都适用。如算数类型、类、指针等。底层const则与指针和引用等复合类型的基本类型部分有关。比较特殊的是,指针类型既可以是顶层const也可以是底层const,这一点和其他类型相比区别明显。

1
2
3
4
5
6
int i = 0;                  
int *const p1 = &1; // 不能改变p1的值,这是一个顶层const。
const int ci = 42; // 不能改变ci的值,这是一个顶层const
const int *p2 = &ci // 允许改变p2的值,这是一个底层const
const int *const p3 = p2; // 不能修改p3的值,右边顶层const,左边是底层const
const int &r = ci; // 用于声明引用的const都是底层const

当执行对象的拷贝操作时,常量是顶层const还是底层const区别明显。其中,顶层const不受什么影响:

1
2
i = ci;     // 正确:拷贝ci的值,ci是一个顶层const,对此操作无影响
p2 = p3; // 正确:p2和p3指向的对象类型相同,p3顶层const的部分不受影响

执行拷贝操作并不会改变被拷贝对象的值,因此,拷入和拷出的对象是否是常量都没什么影响。

另一方面,底层const的限制却不能忽视。当执行对象的拷贝操作时,拷入和拷出的对象必须具有相同的底层const资格,或者来年各个对象的数据类型必须能够转换。一般来说,非常量可以转换成常量,反之则不行:

1
2
3
4
5
int *p = p3;        // 错误,p3有const定义而p没有。
p2 = p3; // 正确:p2和p3都是底层const
p2 = &1; // 正确,int*能转换成 const int*
int &r = ci; // 错误,普通的int&不能绑定在int常量上
const int &r2 = 1; // 正确:const int&可以绑定到一个普通int上。

p3即是顶层const也是底层const,拷贝p3时可以不在乎它是一个顶层const,但是必须说清楚它指向的对象得是一个常量。因此,不能用p3区初始化p,因为p指向的是一个普通的(非常量)整数。另一方面,p3的值可以赋给p2,是因为这两个指针都是底层const,尽管p3同时也是一个常量指针(顶层const),仅就这次赋值而言不会有什么影响。

constexpr和常量表达式

常量表达式是指值不会发生改变并且在编译过程就能得到计算结果的表达式。显然,字面值属于常量表达式。
用常量表达式初始化的const对象也是常量表达式。后面将会提到。
C++语言中有几种情况下是要用到常量表达式的。

1
2
3
4
const int max_files = 20;           //
const int limit = max_files + 1; //
int staff_size = 27; //
const int sz = get_size(); //

尽管staff_size的初始值是个字面值常量,但由于它的数据类型只是一个普通int而非const_int,所以他不属于常量表达式。另一方面,尽管sz本身是一个常量,但它的具体值知道运行时才能获取到,所以也不是常量表达式。