第1章 变量和基本类型
第1章 变量和基本类型
1.1 基本内置类型
C++定义了一套包括算术类型和空类型在内的基本数据类型,其中算术类型包含了字符型、整数型、布尔值和浮点数,空类型不对应具体的值,仅用于一些特殊的场合(如函数不返回任何值时使用空类型作为返回类型)。
1.1.1 算术类型
算术类型分为两类——整型和浮点型。
算术类型的数据大小(该类型的数据所占的比特数或字节数)在不同的机器上有所差别,C++标准规定了数据大小的最小值,允许不同的编译器赋予这些类型更大的尺寸。下表是GCC编译器下数据类型所占字节数和取值范围:
数据类型 | 32位(Bytes) | 64位(Bytes) | 取值范围 |
---|---|---|---|
bool | 1 | 1 | true,false |
char | 1 | 1 | -128 ~ 127 |
unsigned char | 1 | 1 | 0 ~ 255 |
wchar_t(宽字符) | 2 | 2 | 0 ~ 65535 |
char16_t(Unicode字符) | 2 | 2 | 0 ~ 65535 |
char32_t(Unicode字符) | 4 | 4 | 0 ~ 4294967295 |
short int | 2 | 2 | -32768 ~ 32767 |
unsigned short int | 2 | 2 | 0 ~ 65535 |
int | 4 | 4 | -2147483648 ~ 2147483647 |
unsigned int | 4 | 4 | 0 ~ 4294967295 |
long int | 4 |
8 |
- |
unsigned long int | 4 |
8 |
- |
long long int | 8 | 8 | -9223372036854775808 ~ 9223372036854775807 |
unsigned long long int | 8 | 8 | -9223372036854775808 ~ 9223372036854775807 |
float | 4 | 4 | 3.4E +/- 38(6~7位有效数字) |
double | 8 | 8 | 1.7E +/- 308 (14~15位有效数字) |
long double | 16 | 16 | 1.7E +/- 308 (18~19位有效数字) |
如何选择数据类型?以下是一些经验准则:
- 当明确知晓数值不可能为负时,选用无符号类型;
- 使用int执行整数运算;
- 在算术表达式中不用使用char或bool;
- 执行浮点数运算时选用double;
1.1.2 类型转换
当把一种算术类型的值赋给另外一种类型时,类型所能表示的值的范围决定了转换的过程:
bool b = 42; // b = true
int i = b; // i = 1
i = 3.14; // i = 3
double pi = i; // pi = 3.0
unsigned char c = -1; // 假设char占8bit,c = 255;
signed char c2 = 256; // 假设char占8bit,c2的值是未定义的
- 当把一个非布尔类型的算术值赋给布尔类型时,算术值为0则结果为
false
,否则结果为true
; - 当把一个布尔值赋给非布尔类型时,布尔值为
false
则结果为0
,否则结果为1
; - 当把一个浮点数赋给整数类型时,进行了近似处理,结果值将仅保留浮点数中的小数点之前的部分;
- 当把一个整数值赋给浮点类型时,小数部分记为0,若该整数所占的空间超过了浮点类型的容量,精度有可能损失;
- 当赋给无符号类型一个超出它表示范围的值时,结果是初始值对无符号类型表示数组总数取模后的余数;例如:8bit大小的unsigned char数据范围是0~255,若赋给了一个区间以外的值,则实际结果是该值对256取模后的余数,-1赋给8bit的unsigned char时,实际上是赋给了255,对256取模之后还是255;
- 当赋给带符号的类型一个超出它数据范围的值时,结果是未定义(undifined)的,此时程序可能继续工作,可能崩溃,也可能生成垃圾数据!
程序应当尽量避免依赖于实现环境的行为,这样的程序是不可移植的!
当程序的某处使用了一种算术类型的值而实际上所需要的是另一种类型的值时,编译器同样会执行上述的转换:
int i = 42;
if(i) // if语句的条件将为true
i = 0;
含有无符号类型的表达式
当一个算术表达式中既有无符号数又有int值时,那个int值将会转换成无符号数:
unsigned u = 10;
int i = -42;
std::cout << i + i << std::endl; // 输出-84
std::cout << u + i << std::endl; // 若int占4字节,则输出4294967264(即2^32 - 32)
当从无符号数中减去一个值时,不管这个值是不是无符号数,都必须确保结果不能是一个负值:
unsigned u1 = 42, u2 = 10;
std::cout << u1 - u2 << std::endl; // 正确:输出32
std::cout << u2 - u1 << std::endl;
无符号数不会小于0,因此在循环中要注意循环条件:
for(unsigned u = 10; u >= 0; u++) // 错误,变量u永远也不会小于0,陷入死循环
std::cout << u << std::endl;
切勿混用带符号类型和无符号类型!
若表达式里既含有带符号类型又含有无符号类型,带符号数会自动转换成无符号数
,当带符号类型取值为负值时,会出现异常结果!
1.1.3 字面值常量
一个形如42的值被称为字面值常量(literal)。每个字面值常量都对应一种数据类型,字面值常量的形式和值决定了它的数据类型;
整型和浮点型字面值
可以将整型字面值写作十进制数、八进制数或十六进制数的形式——以0开头的整数表示八进制数,以0x
或0X
开头的表示十六进制数:
20 /* 十进制 */ 024 /* 八进制 */ 0x14 /* 十六进制 */
整型字面值具体的数据类型由它的值和符号决定,默认情况下,十进制字面值是带符号数,八进制和十六进制字面值可能是带符号数也可能是无符号数。十进制字面值的类型是int
、long
和long long
中字节数最小的那个(int),前提是该类型能容纳下当前值;八进制和十六进制字面值的类型是能容纳其数值的int、unsigned int、long、unsigned long、long long和unsigned long long
中的字节数最小的那个。类型short没有对应的字面值!
浮点型字面值是一个小数或以科学计数法表示的指数,其中指数部分用E或e表示:
3.14159 3.14159E0 0. 0e0 .001
浮点型字面值默认是一个double
类型。
字符和字符串字面值
由单引号括起来的一个字符称为char型字面值,由双括号括起来的0个或多个字符则被称为字符串型字面值:
'a' // 字符字面值
"Hello World!" // 字符串字面值
字符串字面值的类型实际上是由常量字符构成的数组,编译器为每个字符串的结尾加上一个空字符'\0'
,因此字符串字面值的实际长度要比它的实际字符个数多1。
若两个字符串字面值位置紧邻并且仅由空格、缩进和换行符分隔,则它们实际上是一个整体。当字符串字面值比较长,写在一行里不合适时,就可以采取分开书写的方式:
std::cout << "a really, really long string literal "
"that spans two lines" << std::endl;
转义序列
有两类字符程序员不能直接使用:
- 不可打印的字符,如退格或其他控制字符,因为它们没有可视的图符;
- 在C++中有特殊含义的字符(单引号、双引号、问号、反斜线)
在这种情况下需要使用转义序列(escape sequence),转义序列均以反斜线作为开始:
转义序列 | 表示 |
---|---|
换行符 | \n |
回车符 | \r |
横向制表符 | \t |
纵向制表符 | \v |
退格符 (将光标回退回前一个字符) | \b |
报警(响铃)符(标准输出中输出该符时系统自带的扬声器会发出"叮"的一声) | \a |
进纸符 (将光标移动到下一页开头) | \f |
反斜线 | \\ |
双引号 | \" |
单引号 | \' |
问号 | ? |
在程序中,转义序列被当做一个字符使用:
std::cout << '\n'; // 转到新的一行
std::cout << "\tHi!\n"; // 输出一个制表符,输出"Hi!",转到新的一行
也可以使用泛化的转义序列,其形式是\x后跟一个或多个十六进制数字或者\后紧跟1个、2个或3个八进制数字,其中数字部分表示的是字符对应的数值:
\7 (响铃) \12 (换行符) \40(空格)
\0 (空字符) \115(字符M) \x4d(字符M)
注意:如果反斜线后面跟着的八进制数字超过3个,则只有前3个数字与反斜线\构成转义序列,例如\1234表示两个字符——123对应的字符和字符4。
而\x要用到后面跟着的所有字符,例如\x1234表示1234这个十六进制数对应的字符
指定字面值的类型
通过添加前缀和后缀可以改变整型、浮点型和字符型字面值的默认类型:
前缀 | 含义 | 类型 |
---|---|---|
u | Unicode16字符 | char16_t |
U | Unicode32字符 | char32_t |
L | 宽字符 | wchar_t |
u8 | UTF-8(仅用于字符串字面常量) | char |
后缀 | 含义 | 类型 |
---|---|---|
u or U | 无符号整型 | unsigned |
l or L | 长整型 | long |
ll or LL | 长整型 | long |
f or F | 单精度浮点型 | float |
l or L | 扩展精度浮点型 | long double |
L'a' // 宽字符字面值,类型是wchar_t
u8"Hi" // utf-8字符串字面值
42ULL // 无符号长整型字面值,类型是unsigned long long
1E-3F // 单精度浮点型字面值,类型是float
3.14159L // 扩展精度浮点型字面值,类型是long double
布尔字面值和指针字面值
true
和 false
是布尔类型的字面值,nullptr
是指针字面值
1.2 变量
变量(variable)提供一个具名的、可供程序操作的存储空间,C++中的每个变量都具有其数据类型,数据类型决定着变量所占内存空间大小和布局方式、该空间能存储的值的范围,以及变量能参与的运算。对C++程序员来说,“变量(variable)”和“对象(object)”一般可以互换使用
。
什么是对象?
对象实际上是指一块能存储数据并具有某种类型的内存空间。
1.2.1 变量定义
变量定义的基本形式是:首先是类型说明符,随后紧跟着一个或多个变量名组成的列表,其中变量名以逗号分隔,最后以分号结束。列表中每个变量名的类型都由类型说明符指定,定义时还可以为一个或多个变量赋初值:
int sum = 0, value,
units_sold = 0; // sum、value和units_sold都是int,sum和units_sold初值为0
Sales_item item; // item的类型是Sales_item
std::string book("0-201-78345-X"); // book通过一个string字面值初始化
初始值
当对象在创建时获得了一个特定的值,则说这个对象被初始化(initialized)了,用于初始化变量的值可以是任意表达式:
double price = 109.99, discount = price * 0.6; // price用字面量初始化,discount用表达式的值初始化
double salePrice = applyDiscount(price, discount); // salePrice用函数applyDiscount返回值初始化
初始化和赋值的区别:
初始化不是赋值,初始化的含义是创建变量时赋予其一个初始值,而赋值的含义是把对象的当前值擦除,然后以一个新的值代替
列表初始化
C++定义了初始化的好几种不同形式:
int units_sold = 0;
int units_sold = {0};
int units_sold{0};
int units_sold(0);
从C++11新标准开始,用花括号来初始化变量得到了全面应用,这种初始化的形式被称为列表初始化(list initialization)
,无论是初始化对象,还是为对象赋新值,都可以使用列表初始化。
当用于内置类型的变量时,若使用列表初始化并且初始值存在丢失信息的风险时,编译器将会报错:
long double ld = 3.1415926536;
int a{ld}, b = {ld}; // 错误:转换未执行,因为存在丢失信息的风险
int c(ld), b = ld; // 正确:转换执行了,且确实丢失了部分值
默认初始化
如果定义变量时没有指定初值,那么变量将被默认初始化(default initialization),此时变量被赋予了“默认值”,默认值是什么取决于变量类型:
-
如果是内置类型的变量未被显式地初始化,它的值将由定义的位置决定:
- 定义于
任何函数体之外的变量
将被初始化为0; - 定义于
函数体内部的内置类型的变量
将不被初始化(uninitialized),一个未被初始化的内置类型变量的值是未定义的,如果试图拷贝或以其他形式访问此类值将引发错误!
- 定义于
-
每个类各自决定其初始化对象的方式,是否允许不经初始化就定义对象也由类自己决定,若类允许这种行为,它将决定对象的初始值到底是什么,绝大多数类都支持无需显式初始化而定义对象,这样的类提供了一个合适的默认值:
std::string empty; // empty非显式化地初始化为一个空串
Sales_item item; // 被默认初始化的Sales_item对象
一些类则要求每个对象都显式地初始化,此时如果创建了一个该类的对象而未对其做明确的初始化操作的话,将会引发错误。
未初始化变量将容易引发运行时故障!
1.2.2 变量声明和变量定义
为了允许把程序拆分成多个逻辑部分来编写,C++语言支持分离式编译(separate compilation)机制,该机制允许程序分割为若干个文件,每个文件可独立编译。
为了支持分离式编译,C++将变量声明和定义区分开来,声明(declaration)使得名字为程序所知,一个文件如果想使用别的文件定义的变量的名字则必须包含对那个名字的声明,而定义(definition)则负责创建与名字关联的实体。
变量声明规定了变量的类型和名字,但定义除了定义变量的类型和名字之外,还申请了存储空间,还可能会为变量赋一个初始值。
若想声明一个变量而非定义它,可以在变量名前添加关键字extern
,并且不要显式地初始化变量,任何包含了显式初始化的声明将成为定义,这样也就抵消了extern的作用:
extern int i; // 声明i而非定义i
int j; // 声明并定义j
extern double pi = 3.1416; // 定义
在函数体内部,如果初始化一个由extern关键字标记的变量将引发错误。
int main()
{
extern int i = 5; // 错误
}
变量只能被定义一次,但是可以被多次声明。
静态类型
C++是一种静态类型(statically typed)语言,其含义是在编译阶段检查类型,其中检查类型的过程被称为类型检查(type checking),对象的类型决定了对象能参与的运算,在C++语言中,编译器负责检查数据类型是否支持要执行的运算,如果试图执行类型不支持的运算,编译器将会报错。
1.2.3 标识符
C++ 标识符(indentifier)由字母、数字、下划线组成,其中必须以字母或下划线开头,不能以数字开头,标识符长度没有限制,但对大小写敏感。C++ 保留了一些名字作为关键字,这些名字不能被用作标识符。
标识符命名规范
以下命名规范能有效提高程序的可读性:
- 标识符命名要能“见名知义”
- 变量名一般用小写字母,如index,不要使用Index和INDEX
- 自定义的类名一般以大写字母开头,如Sales_item
- 若标识符由多个单词组成,则单词间应该要有明显区分,如student_loan或studentLoan,不要使用studentloan
1.2.4 名字的作用域
同一个名字在程序的不同位置,可能指向不同的实体。
作用域是程序的一部分,在其中名字有特殊的含义,C++中大多数的作用域都以花括号分隔。同一个名字在不同的作用域中可能指向不同的实体,名字的有效区域始于名字的声明语句,以声明语句所在的作用域末端为结束。
#include <iostream>
int main() {
int sum = 0;
for (int val = 1; val <= 10; i++) {
sum += val;
}
std::cout << sum << std::endl;
}
名字main定义于所有花括号之外,它和其他大多数定义在函数体之外的名字一样都具有全局作用域(global scope),一旦声明之后,全局作用域内的名字在整个程序范围内都可使用。名字sum定义于main函数所限定的作用域内,从声明sum到main函数结束为止都可以访问它,但是出了main函数所在的快就无法访问了,因此变量sum拥有块作用域(block scope)。名字val定义于for语句内,在for语句内可以访问,但是在main函数中的其他部分就不能访问它了。
建议:在第一次使用变量时再定义它
作用域的嵌套
作用域能彼此包含,被包含的作用域被称为内层作用域(inner scope),包含着别的作用域的作用域被称为外层作用域(outer scope)。
作用域中一旦声明了某个名字,它所嵌套着的所有作用域中都能访问该名字,同时允许在内层作用域中重新定义外层作用域已有的名字(不建议)
#include <iostream>
int reused = 42; // reused拥有全局作用域
int main() {
int unique = 0; // unique拥有块作用域
std::cout << reused << std::endl; // 输出42,使用全局变量reused
int reused = 0; // 新建局部变量reused,覆盖了全局变量reused
std::cout << reused << std::endl; // 输出0,使用局部变量reused
std::cout << ::reused << std::endl; // 输出42,显式地指定使用全局变量reused
}
::
是作用域操作符,因为全局作用域本身并没有名字,所以当作用域操作符左侧为空时,将向全局作用域发出请求获取作用域操作符右侧名字对应的变量。
1.3 复合类型
复合类型是基于其他类型定义的类型,C++有多种复合类型——引用和指针等。
一条声明语句由一个基本数据类型(base type)和紧随其后的一个声明符(declarator)列表组成,每个声明符命名了一个变量并指定该变量为与基本类型有关的某种类型。
1.3.1 引用
C++11中新增了一种引用即所谓的右值引用(rvalue reference),这种引用主要用于内置类中,一般说的引用(reference)实际上指的是左值引用(lvalue reference)。
引用为对象起了一个别名,引用类型引用(refer to)另外一种类型,通过将声明符写成&d的形式来定义引用类型,其中d是声明的变量名:
int ival = 1024;
int &refVal = ival; // refVal指向ival(refVal是ival的一个别名)
int &refVal1; // 报错,引用必须被初始化
一般在初始化变量时,初始值会被拷贝到新建的对象中,而定义引用时,程序把引用和它的初始值绑定(bind)在一起,而不是把初始值拷贝给引用,一旦初始化完成,引用将和它的初始值对象一直绑定在一起;引用无法重新绑定到另外一个对象,引用必须初始化!
引用即别名
引用并非对象,相反的,它只是为一个已经存在的对象所起的另外一个名字。
定义了一个引用之后,对其进行的所有操作都是在与之绑定的对象上进行的:
refVal = 2; // 把2赋值给refVal指向的对象,实际上是赋值给了ival
int ii = refVal; // 等同于ii = ival
为引用赋值,实际上是把值赋给了与引用绑定的对象;获取引用的值,实际上是获取了与引用绑定的对象的值;用引用作为初始值,实际上是与引用绑定的对象作为初始值:
int &refVal2 = refVal; // 正确:refVal2绑定到了refVal绑定的对象上,这里就是绑定到ival上
int i = refVal; // 正确:i被初始化为ival的值
因为引用本身不是一个对象,因此不能定义引用的引用!
引用的定义
允许在一条语句中定义多个引用,每个引用标识符都必须以符号&开头:
int i = 1024, i2 = 2048;
int &r = i, r2 = i2; // r是引用,r2是int
int i3 = 1024, &ri = i3; // i3是int,ri是一个引用,与i3绑定到一起
int &r3 = i3, &r4 = i2; // r3和r4都是引用
所有引用的类型都要与绑定的对象严格匹配,并且引用只能绑定在对象上,不能与字面值或者某个表达式的计算结果绑定在一起:
int &refVal3 = 10; // 错误,引用类型的初始值必须是一个对象
double dval = 3.14;
int &refVal4 = dval; // 错误,此处引用类型的初始值必须是int对象
1.3.2 指针
指针(pointer)是指向(point to)另外一种类型的复合类型,与引用类似,指针也实现了对其他对象的间接访问,然而指针与引用相比有很多不同:
- 指针本身就是一个对象,允许对指针赋值和拷贝,并且指针在生命周期内可以先后指向几个不同的对象;
- 指针无需在定义时赋初值;
和其他内置类型一样,在块作用域定义的指针如果没有被初始化,也将拥有一个不确定的值;在全局作用域定义的指针如果没有被初始化将会被默认初始化为NULL。
定义指针类型的方法是将声明符写成*d的形式,其中d是变量名。如果在一条语句中定义了几个指针变量,那么每个变量前都必须加上符号*:
int *ip1, *ip2; // ip1和ip2都是指向int型对象的指针
double *dp1, dp2; // dp1是指向double型对象的指针,dp2是double型对象
获取对象的地址
指针存放某个对象的地址,要想获取该地址,则需要取地址符&:
int ival = 42;
int *p = &ival; // p变量存放变量ival的地址,或者说p是指向变量ival的指针
因为引用不是对象,因此不能定义指向引用的指针!
所有指针类型都要和他所指向的对象严格匹配:
double dval;
double *pd = &dval; // 正确:初始值是double型对象的地址
double *pd1 = pd; // 正确:初始值是指向double对象的指针
int *pi = pd; // 错误:指针pi和pd的类型不匹配
pi = &dval; // 错误:试图把double型对象的地址赋值给int型指针
指针值
指针的值应属下列4中状态之一:
- 指向一个对象
- 指向紧邻对象所占空间的下一个位置
- 空指针,意味着指针没有指向任何对象
- 无效指针,即上述情况之外的其他值
试图拷贝或以其他方式访问无效指针的值都将引发错误!编译器并不负责检查此类错误,这和试图使用未经初始化的变量是一样的,访问无效指针将导致无法预计的后果,因此程序员必须保证给定的任意指针是否有效。
尽管第2种和第3种状态的指针是有效的,但是使用它们同样有限制,因为它们显然没有指向任何具体对象,因此试图访问这类指针对象的行为不被允许!
利用指针访问对象
如果指针指向了一个对象,则允许使用解引用符*来访问该对象:
int ival = 42;
int *p = &ival; // p存放着变量ival的地址,或者说p是指向ival的指针
std::cout << *p; // 由符号*得到指针p所指的对象,输出42
对指针解引用会得到所指的对象,因此如果给解引用的结果赋值,实际上也就是给指针所指的对象赋值:
*p = 0;
std::cout << *p << " " << ival << std::endl; // 输出 0 0
解引用操作仅适用于指向了某个对象的有效指针。
空指针
空指针(null pointer)不指向任何对象,在试图使用一个指针之前,可以首先检查一下它是否为空,以下是几个生成空指针的方法:
int *p1 = nullptr; // 等价于int *p1 = 0;(C++11)
int *p2 = 0; // 直接将p2初始化为字面量0
int *p3 = NULL; // 等价于int *p3 = 0;
得到空指针最简单的方法就是用字面值nullptr
来初始化指针,nullptr
是一种特殊的字面值,它可以被转换成任意其他类型的指针;过去的程序还会使用NULL
这一预处理变量(preprocessor variable)来给指针赋值,这个变量在头文件cstdlib中定义,它的值为0。
在C++11及之后的标准下,C++程序最好使用nullptr,尽量避免使用NULL。
把int变量直接赋值给指针是错误的行为,即使int变量的值恰好等于0也不行:
int zero 0;
int *p = zero; // 错误,不能把int型变量直接赋值给指针
建议:初始化所有指针!
赋值和指针
指针和引用都能提供对其他对象的间接访问,然而,引用本身并不是一个对象,一旦定义了引用,就无法再令其绑定到另外的对象,之后每次使用这个引用都是访问它最初绑定的那个对象;而指针和它存放的地址之间就没有这种限制了,和其他任何对象一样,给指针赋值就是令它存放一个新的地址,从而指向一个新的对象:
int i = 42;
int *pi = 0; // pi被初始化,但没有指向任何对象
int *pi2 = &i; // pi2被初始化,存有i的地址
int *pi3; // 如果pi3定义于块作用域内,则pi3的值是无法确定的
pi3 = pi2; // pi2和pi3此时指向同一个对象
pi2 = 0; // pi2现在不指向任何对象了
其他指针操作
只要指针拥有一个合法值,那么就能将它用在条件表达式中,此时条件表达式的值为true;若指针的值为0(nullptr、NULL),那么条件表达式的值为false。
int ival = 1024;
int *pi = 0; // pi合法,是一个空指针
int *pi2 = &ival; // pi2合法,存在着ival的指针
if(pi) // pi的值是0,因此条件的值为false
//...
if(pi2) // pi2指向ival,它的值不是0,因此条件的值为ture
//...
对于两个相同类型的合法指针,可以使用相等运算符(==)或不等运算符(!=)来比较它们,比价的结果是布尔类型。
如果两个指针存放的地址相同,则它们相等,反之,它们不相等。这里两个指针存放的地址值相同有三种可能:它们都为空、都指向同一个对象,或者都指向了同一个对象的下一地址。
void*指针
void*
是一种特殊的指针,可用于存放任何对象的地址,一个void*
指针存放着一个地址,这点和其他指针类似,不同的是,程序员并不了解该地址中到底是一个什么类型的对象:
double obj = 3.14, *pd = &obj;
void *pv = &obj; // 正确,void*可以存放任意类型对象的指针
pv = pd;
不能直接操作void*指针所指向的对象,因为并不知道这个对象到底是什么类型!
1.3.3 理解复合类型的声明
变量的定义包括一个基本数据类型(Base type)和一组声明符(declarator),在同一条定义语句中,虽然基本数据类型只有一个,但是声明符的形式可以不同,即一条定义语句可以定义出多个不同类型的变量:
int i = 1024, *p = &i, &r = i; // i是int型整数,p是int型指针指向i,r是对i的引用
定义多个变量
在定义语句中类型修饰符(*或&)并不会作用于本次定义的所有变量:
int *p1, p2; // p1是指向int的指针,p2是int
通常涉及指针和引用的声明有两种写法:
- 第一种,把修饰符与变量标识符写在一起,这种写法着重强调变量具有的复合类型:
int *p1, *p2;
- 第二种,把修饰符和类型写在一起,并且每条语句只定义一个变量,这种形式强调本次声明定义了一种复合类型:
int* p1;
int* p2;
指向指针的指针
指针是内存中的对象,和其他对象一样也有自己的地址,因此允许把指针的地址再存放到另一个地址中,即指向指针的指针。
int ival = 1024;
int *pi = &ival; // pi指向一个int型的整数
int **ppi = π // ppi 指向一个int型的指针
需要对指向指针的指针进行两次解引用才能得到最终指向的对象的值:
std::cout << ival << " " <<*pi << " " << **ppi << std::endl;
指向指针的引用
引用本身不是一个对象,因此不能定义指向引用的指针;但指针是对象,因此可以定义对指针的引用:
int i = 42;
int *p;
int *&r = p; // r是对指针p的引用
r = &i; // r引用了一个指针,因此给r赋值&i就是令p指向i
*r = 0; // 解引用r得到i,也就是p指向的对象,即将i的值改为0
对于复杂的复合类型变量,想要确定变量的类型到底是什么,最简单的方法是从右到左阅读变量的定义,离变量最近的修饰符对变量的类型有最直接的影响。
1.4 const限定符
有时候需要定义这样一种变量,希望它的值不被改变,为了满足这一要求,可以使用关键字const
对变量的类型加以限定:
const int BUFF_SIZE = 512;
任何试图对const对象进行赋值的行为都将引发错误:
BUFF_SIZE = 1024; // 错误:试图向const对象写值
因为const对象一旦创建之后就不能再更改,所以const对象必须被初始化,初始值可以是任意复杂的表达式:
const int j = get_size(); // 正确,运行时初始化
const int j = 42; // 正确,编译时初始化
const int k; // 错误:k是一个未经初始化的变量
初始化和const
与非const对象相比,const类型的对象能完成非const对象所能参与的大多数操作,主要限制是只能在const类型的对象上执行不改变其内容的操作。
注意初始化是一种不改变const类型的操作。
int i = 42;
const int ci = i; // 正确,i的值被拷贝给了ci
int j = ci; // 正确,ci的值被拷贝给了j
默认情况下,const对象仅在文件内有效
当以初始化的方式定义一个const对象时,编译器将在编译时把用到该变量的地方都替换成对应的值,为了执行该操作,编译器必须知道const对象的初始值,如果程序包含多个文件,则每个使用了const对象的文件都必须能访问它的初始值,要做到这一点,就必须在每个用到变量文件中都有对它的定义,为了支持这一用法同时避免对同一变量的重复定义,默认情况下,const对象被设定为仅在文件内有效,当多个文件内出现了同名的const变量时,其实等用于在不同文件中分别定义了独立的变量。
当想要const变量在多文件之间共享时,需要对const变量的声明和定义都添加extern
关键字,这样就不用在多文件中重复定义了:
// file1.cpp
extern const int BUFF_SIZE = func(); // 在file1.cpp中定义并初始化了一个常量,该常量可以被其他文件访问
// 此处的extern是表示定义的变量可以被其他文件使用
// file1.h
extern const int BUFF_SIZE; //与file1.cpp中定义的BUFF_SIZE是同一个
// 此处的extern是表示声明的变量并不是本文件独有的,该变量的定义在其他文件中
1.4.1 const的引用
可以把引用绑定到const
对象上,这通常被称作对常量的引用(reference to const),与普通引用不同的是,对常量的引用不能被用作修改它所绑定的对象:
const int ci = 1024;
const int &r1 = ci; // 正确,引用及其对应的对象都是常量
r1 = 42; // 错误,r1是对常量的引用
int &r2 = c1; // 错误,试图让一个非常量引用指向一个常量对象
注意:非常量引用不能指向常量对象,只有常量引用才能指向一个常量对象
int &ref1 = 42; // 错误,非常量引用不能指向字面量常量
const int &ref2 = 42; // 正确
初始化和对const的引用
初始化常量时允许使用任意表达式作为初始值,允许初始化时为一个常量引用绑定非常量的对象、字面值、甚至是表达式:
int i = 42;
const int &r1 = i; // 正确,允许将常量引用绑定到非常量对象上
const int &r2 = 42; // 正确,允许将常量引用绑定到字面值上
const int &r3 = r1 * 2; // 正确,允许将常量引用绑定到表达式上
int &r4 = r1 * 2; // 错误,r4是非常量引用不能指向常量对象
对const的引用可能引用一个并非const的对象
常量引用仅仅对引用的操作做出了限制,对于引用的对象本身是否是常量未做限定,因此对象可能是一个非常量,允许通过其他途径改变它的值:
int i = 42;
int &r1 = i; // 引用r1绑定对象i
const int &r2 = i; // 常量引用r2也绑定对象i
r1 = 0; // r1并非常量,将i的值修改为0
r2 = 0; // 错误,r2是常量引用
1.4.2 指针和const
指向常量的指针
与引用一样,指针也可以指向常量或非常量。指向常量的指针(pointer to const)不能用于改变其所指对象的值
,若想存放常量对象的地址,只能使用指向常量的指针:
const double pi = 3.14; // pi是一个常量,它的值不能改变
double *ptr = π // 错误:ptr是一个普通指针
const double *cptr = π // 正确
*cptr = 42; // 错误,不能通过指向常量的指针来改变指向对象的值
与常量引用一样,指向常量的指针也没有限定所指向的对象必须是一个常量,指向常量的指针无法通过该指针改变指向对象的值,但是那个对象的值可以通过其他途径改变。
常量指针
指针是对象而引用不是,因此可以把指针本身定义为常量。常量指针(const pointer)必须初始化,并且一旦初始化完成,那么它的值(即存放指向对象的地址)就不能再改变了。把*放在const关键字之前用以说明指针是一个常量,即不变的是指针的值而不是指针所指向的值:
int errNum = 0;
int *const curErr = &errNum; // currErr是常量指针,curErr将一直指向errNum
const double pi = 3.14159;
const double *const pip = π // pip是一个指向常量的常量指针
常量指针只是指指针本身是一个常量,但是允许通过常量指针修改其所指向的对象的值:
*curErr = 1; // 正确
*pip = 3.14; // 错误,pip是一个指向常量的常量指针,既不能通过指针修改指向对象的值,也不能修改指针本身的值
1.4.3 顶层const
指针本身是一个对象,它又可以指向另外一个对象,因此指针本身是否是常量已经指针指向的对象是否是一个常量是不同的概念。名词顶层const(top-level const)表示指针本身就是一个对象,名词底层const(low-level const)表示指针所指的对象是一个常量。
更一般地,顶层const可以表示任意的对象时常量,这对任何数据类型都适用,比如算术类型、类、指针等。底层const则与指针和引用等复合类型的基本类型部分相关。比较特殊的是指针既可以是顶层const也可以是底层const。
int i = 0;
int *const p1 = &i; // p1是常量指针,不能改变p1的值,这是一个顶层const
const int c1 = 42; // 不能改变c1的值,这是一个顶层const
const int *p2 = &i; // 允许改变p2的值,这是一个底层const
const int *const p3 = p2; // 靠右的const是顶层const,靠左的const是底层const
const int &r = ci; // 用于声明引用的const都是底层const
当执行拷贝操作时,常量是顶层const还是底层const区别明显,对于顶层const,顶层const几乎不受拷贝操作的影响:
i = ci; // 正确,拷贝ci的值,ci是一个顶层const,对称操作无影响
p2 = p3; // 正确,p2和p3指向的对象类型相同,p3顶层const的部分不影响
指向拷贝操作并不会改变被拷贝对象的值,因此拷入和拷出的对象是否是常量都没有什么影响。
然而底层const的限制却不能忽视,当执行对象的拷贝操作时,拷入和拷出的对象都必须具有相同的底层const资格,或者两个对象的数据类型必须能够互相转换。一般来说,非常量可以转换成常量,常量不能转换成非常量:
int *p = p3; // 错误,p3包含底层const的定义,而p没有
p2 = p3; // 正确,p2和p3都是底层const
p2 = &i; // 正确,非常量能转换成常量,int*能转换成const int*
int &r = ci; // 错误,非常量引用不能绑定到常量上
const int &r2 = i; // 正确,常量引用可以绑定到非常量上
1.4.4 constexpr和常量表达式
常量表达式
常量表达式(const expression)是指值不会改变,并且在编译过程就能得到计算结果的表达式。字面值显然属于常量表达式。
const int max_files = 20; // max_files是常量表达式
const int limit = maxfiles + 1; // limit是常量表达式
int staff_size = 27; // staff_size不是常量表达式
const int sz = get_size(); // sz不是常量表达式
constexpr变量
C++11新标准规定,允许将变量声明为constexpr
类型以便由编译器来验证变量的值是否是一个常量表达式。声明为constexpr的变量一定是一个常量,并且必须用常量表达式初始化。
constexpr int mf = 20; // 20是常量表达式
constexpr int limit = mf + 1; // mf + 1是常量表达式
constexpr int sz = size(); // 只有当size()是一个constexpr函数时才是一个正确的声明
不能使用普通函数的返回值作为constexpr
变量的初始值,但是C++11新标准运行定义一种特殊的constexpr函数,这种函数在编译时就可以计算其结果,这样就能用constexpr
函数的返回值去初始化constexpr变量了。
一般来说,如果认定变量是一个常量表达式,那么就把它定义成constexpr类型。
字面值类型
常量表达式的值在编译时就可以得到其结果,因此对于声明为constexpr
时用到的类型必须有所限制,这些类型一般比较简单,值也显而易见,容易得到,这些类型就被称为字面值类型(literal type)。
算术类型、引用、指针都属于字面值类型,自定义类、IO库、string类型则不属于字面值类型,不属于字面值类型也就不能定义为constexpr
。
定义为constexpr
的指针和引用的初始值受到严格限制,一个constexpr
指针的初始值必须是nullptr
或0,或者是存储于某个固定地址中的对象。函数体内定义的变量一般来说并非存放在固定地址中,因此constexpr指针不能指向这样的变量,相反的,定义于所有函数体之外的对象其地址固定不变,能用来初始化constexpr
指针。
指针与constexpr
在constexpr
声明中如果定义了一个指针,限定符constexpr
仅对指针有效,与指针所指的对象那个无关:
const int *p = nullptr; // p是一个指向整型常量的指针
constexpr int *q = nullptr; // p是一个指向整型的常量指针
constexpr
把它所定义的对象置为了顶层const。
与其他常量指针类型,constexpr
指针既可以指向常量,也可以指向一个非常量:
constexpr int *np = nullptr; // np是一个指向整型的常量指针
int j = 0;
constexpr int i = 42;
constexpr const int *p = &i; // p是常量指针,指向整型常量
constexpr int *p1 = &j; // p1是常量指针,指向普通整型
1.5 处理类型
1.5.1 类型别名
类型别名(type alias)是一个名字,它是某种类型的同义词,使用类型别名可以将复杂类型的名字变得简单明了、易于理解和使用。有两种方法可以用于定义类型别名:
- 使用关键字
typedef
:
typedef double wages; // wages是double的同义词
typedef wages base, *p; // base是double的同义词,p是double*的同义词
- C++11新标准规定了一种新的方法,使用别名声明(alias declaration)类定义类型的别名:
using SI = Sales_item;
别名声明以using作为开始,其后紧跟别名和等号,作用是把等号左侧的名字规定成等号右侧类型的别名。
类型的别名和类型名等价,可以像使用类型名一样使用类型别名:
wages hourly, weekly; // 等价于double hourly, weekly;
SI item; // 等价于Sales_item item;
指针、常量和类型别名
如果某个类型别名代指的是复合类型或者常量,那么把它用到声明语句里就可能产生意向不到的后果:
typedef char* pstring;
const pstring cstr = 0; // cstr是指向char的常量指针
const pstring *ps; // ps是指向char的常量指针
const是对给定类型的修饰,pstring实际上是指向char的指针,因此const pstring是指向char的常量指针,而非指向常量字符的指针!这往往容易误解为一下含义:
const char* cstr = 0;
1.5.2 auto类型说明符
C++11新标准引入了auto
类型说明符,用它就可以让编译器自动分析表达式所属的类型,和只对应一种特定类型的说明符不同,auto
让编译器通过初始值来推算变量的类型,因此auto
定义的变量必须初始化!
auto item = val1 + val2;
// 若val1和val2的类型是Sales_item,则item的类型也为Sales_item
使用auto也能在一条语句中声明多个变量,但因为一条声明语句中只能有一个基本数据类型,因此该语句中的所有变量的初始基本数据类型必须一样:
auto i = 0, *p = &i; // 正确,i是整数,p是指向整型的指针
auto sz = 0, pi = 3.14; // 错误,sz和pi的类型不同
复合类型、常量和auto
编译器推断出来的auto类型有时候和初始值的类型并不完全一样,编译器会适当地改变结果类型使其更符合初始化规则。
使用引用实际上使用的是引用的对象,当引用被用作初始值时,实际上是使用引用的对象的值进行初始化,因此当auto
定义的变量的初始值是引用时,编译器将会把引用的对象作为auto
的类型:
int i = 0, &r = i;
auto a = r; // a是整型
auto
一般会忽略顶层const
,同时底层const
将会保留下来:
const int ci = i, &cr = ci;
auto b = ci; // b是int,ci的顶层const被忽略掉了
auto c = cr; // c是int,cr是ci的别名,ci的顶层const被忽略掉了
auto d = &i; // d是int*
auto e = &ci; // e是一个指向常量整型的指针(对常量对象取地址也是一种底层const)
如果希望推断出的auto
类型是一个顶层const
,则需要明确指出:
const auto f = ci;
还可以将引用的类型设置为auto
,此时引用的初始化规则仍然适用:
auto &g = ci; // g是一个整型常量引用
auto &h = 42; // 错误,非常量引用不能绑定到字面值
const auto &j = 42; // 正确
设置一个类型是auto的引用时,初始值中的顶层const属性仍然保留。
1.5.3 decltype类型指示符
C++11引入了decltype
类型说明符,它的作用是选择并返回表达式的数据类型,在此过程中,编译器分析表达式并得到它的类型,却不计算表达式的值:
decltype(f()) sum = x; // 变量sum的类型就是函数f的返回类型
// 编译器并不实际调用函数f,而是使用当调用发生时f的返回值作为sum的类型
decltype
处理顶层const和引用的方式与auto
不同:如果decltype
使用的表达式是一个变量,则decltype
返回该变量的类型(包括顶层const和引用在内):
const int ci = 0, &cj = ci; // ci是整型常量,cj是整型常量的引用
decltype(ci) x = 0; // x的类型是const int
decltype(cj) y = x; // y的类型是const int&
decltype(cj) z; // 错误,z是一个引用,必须初始化;
decltype和引用
decltype
的表达式可以向decltype返回一个引用类型:
int i = 42, &r = i;
decltype(r) a; // 错误,a是一个引用类型,必须初始化
decltype(r + 0) b; // 正确,r + 0表达式的结果是一个整数,因此b的类型是整型
如果decltype
使用的表达式的内容是解引用操作(*),则decltype
将得到引用类型:
int *p = &i;
decltype(*p) c; // 错误,c的类型是int&,必须初始化
赋值是会产生引用的一类典型表达式,引用的类型就是左值的类型,即如果i是int,则表达式(i = x)的类型是int&
对于decltype使用的表达式来说,如果变量名加上括号,那么返回的类型结果与不加括号时会有所不同——如果decltype使用的是一个不加括号的变量,则得到的是该变量的类型;如果给变量加上了一层或者多层括号最终得到的是引用类型:
decltype((i)) d; // 错误,d是int&,必须初始化
decltype(i) e; // 正确,e是int
decltype((var))的结果永远是引用,而decltype(var)只有当var本身是引用时才是引用。
1.6 头文件与预处理
头文件
如果想要在不同文件中使用同一个类,类就通常可以定义在头文件中,并且最好类名与头文件名保持一致。
头文件通常好汉那些只能被定义一次的实体,如类、const和constexpr变量等。
头文件一旦改变,则包含该头文件的源文件都必须重新编译以获取更新过的声明。
预处理器
保证头文件多次包含还能安全工作的是预处理器。预处理器是在编译之前执行的一段程序,可以部分改变源代码程序。
#include
是常用的一个预处理命令,当预处理器看到#include
标记时,它就会用指定的头文件的内容来替代#include
,即将头文件的内容拷贝粘贴到源文件中。
另一个常用的预处理命令时头文件保护符,头文件保护符依赖于预处理变量,预处理变量有两种状态——已定义和未定义。预处理变量通常基于头文件中的类名,一般预处理变量的名字全部大写。#define
指令把一个名字设定为预处理变量,#ifdef
当且仅当变量已定义时为真,#ifndef
当且仅当变量未定义时为真,一旦检查结果为真,则执行后续操作直至遇到#endif
指令为止,利用头文件包含符可以有效防止重复包含:
#ifndef SALES_DATA_H
#define SALES_DATA_H
#include <string>
struct Sales_data {
std::string bookNo;
unsigned unitsSold = 0;
double revenue = 0.0;
}
#endif
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本