数据类型是程序的基础,它规定了数据的意义,以及能在数据上执行什么操作。
C++的基本数据类型分类如下表。从大类上区分,分为算术类型和空类型。算术类型又分为整型和浮点型。
空类型不对应具体的值,仅用于特殊场合。譬如函数不返回任何值时,使用空类型void作为返回类型。
C++基本数据类型 | ||||
---|---|---|---|---|
类别 | 类别 | 类型 | 含义 | 最小尺寸 |
算术类型 | 整型 | bool | 布尔类型 | 未定义 |
char | 字符 | 8位 | ||
wchar_t | 宽字符 | 16位 | ||
char16_t | Unicode字符 | 16位 | ||
char32_t | Unicode字符 | 32位 | ||
short | 短整型 | 16位 | ||
int | 整型 | 16位 | ||
long | 长整型 | 32位 | ||
long long | 长整型 | 64位 | ||
浮点型 | float | 单精度浮点数 | 6位有效数字 | |
double | 双精度浮点数 | 10位有效数字 | ||
long double | 扩展精度浮点数 | 10位有效数字 | ||
空类型 | void | 空类型 |
算术类型分为整型和浮点型两类。
在不同机器上,算术类型的尺寸有所差别。C++标准只规定了各类型尺寸的最小值(见上表),并允许编译器赋予这些类型更大尺寸。
C++规定,int至少和short一样大,long至少和int一样大,long long至少和long一样大。
浮点类型可以表示单精度、双精度和扩展精度值。C++标准指定了浮点数有效位数的最小值,大多数编译器都实现了更高的精度。
通常,float以一个字(32位)来表示,double以两个字来表示,long double以3或4个字来表示。
通常,float有7个有效位,double有16个有效位,long double用于有特殊需求的硬件,具体实现不同,精度也各不相同
除布尔型和扩展字符型外,其他整型可以分为带符号(signed)和无符号(unsigned)两种。
int、short、long、long long都是带符号的,在其前面添加unsigned即可得到无符号类型。较为特殊的是,unsigned int可以被简写为unsigned。
字符型分为三种——char、signed char、unsigned char。char类型有可能带符号,也可能无符号,具体由编译器决定。char在符号方面的不确定性使得用char进行运算很容易出问题。如果真的需要一个范围较小的整数,要明确指定其类型是signed char或unsigned char。
C++规定,对于带符号类型而言,正值和负值应平衡。譬如signed char类型,表示的范围是-128~127。
如果代码中出现对象的实际类型和定义不符时,程序会进行自动类型转换。
当一个算数表达式中既有无符号数又有int值时,int值就会转换成无符号数。
// 假定int长度为4个字节
unsigned u = 10;
int i = -42;
std::cout << u + i << std::endl; // 输出4294967264
在循环中使用无符号类型,可能导致死循环。
for (unsigned u = 10; u >= 0; --u) {
std::cout << u << std::endl;
}
混用带符号类型和无符号类型,带符号类型会自动转换成无符号数。所以带符号数取值为负时会出现异常结果。切勿混用带符号类型和无符号类型。
类似42这样的值被称作字面值常量(literal)。每个字面值常量都对应一种数据类型,字面值常量的形式和值决定了它的数据类型。
整型字面值可以写作十进制数、八进制数(以0开头)或十六进制数(以0x开头)。
整型字面值具体的数据类型由它的值和符号决定。默认情况下,十进制字面值是带符号数,八进制和十六进制字面值可能是带符号的,也可能是无符号的。
浮点型字面值表现为一个小数或以科学计数法表示的指数,其中指数部分用E或e标识:
由单引号括起来的一个字符称为char型字面值,双引号括起来的零个或多个字符则构成字符串型字面值。
字符串字面值的类型实际上是由常量字符构成的数组,编译器在每个字符串的结尾处添加一个空字符"\0",因此,字符串字面值的实际长度比它的内容多1。
如果两个字符串字面值位置紧邻且仅由空格、缩进和换行符分隔,它们实际上是一个整体。当字符串字面值较长时,可以分开书写,避免一行代码过长。示例代码如下:
std::out << "a really, really long string literal "
"that spans two lines" << std::endl;
有两类字符不能直接使用:
泛化转移序列的形式是,在\x后面紧跟一个或多个十六进制数字,或在\后面紧跟最多三个八进制数字。数字部分表示的是字符对应的数值。譬如\x4d,表示的是字符M。
反斜线\后跟着的八进制数最多3个,即使超过三个,也只有前三个数字与\构成转义序列。
通过添加前缀和后缀,可以改变整型、浮点型和字符型字面值的默认类型。具体如下表所示:
C++指定字面值类型 | |||
---|---|---|---|
字符和字符串字面值 | |||
前缀 | 含义 | 类型 | |
u | Unicode 16字符 | char16_t | |
U | Unicode 32字符 | char32_t | |
L | 宽字符 | wchar_t | |
u8 | UTF-8(仅用于字符串字面常量) | char | |
整型字面值 | 浮点型字面值 | ||
后缀 | 最小匹配类型 | 后缀 | 类型 |
u or U | unsigned | f or F | float |
l or L | long | l 或 L | long double |
ll or LL | long long |
对于整型字面值,可以分别指定是否带符号以及占用多少空间。后缀中有U,该字面值就是无符号类型。譬如以U为后缀的十进制数、八进制数或十六进制数,从unsigned int、unsigned long和unsigned long long中选择能匹配的空间最小的一个作为其数据类型。如果后缀中有L,其字面值类型至少是long。如果后缀中有LL,字面值类型是long long或unsigned long long中的一种。U和L、LL可以混合使用,譬如以UL为后缀的字面值数据类型将视情况取unsigned long或unsigned long long。
true和false是布尔类型的字面值。
nullptr是指针字面值。
变量提供一个具名的、可供程序操作的存储空间。
C++中的每个变量都有数据类型,数据类型决定变量:
变量定义时,可以为一个或多个变量赋初值:
int sum = 0, value, units_sold = 0;
一次定义了多个变量时,对象的名字随着定义,马上就可以使用了。因此在同一条定义语句中,可以用先定义的变量去初始化后定义的变量:
double price = 109.99, discount = price * 0.16;
初始化不是赋值,初始化是在创建变量时赋予其一个初始值,赋值则是将对象的当前值擦除,用一个新值来替代。
C++的初始化有几种不同形式,因此初始化问题具有一定复杂性。我们以定义名为units_sold的int变量并初始化为0为例,以下四条语句均可实现:
int units_sold = 0;
int units_sold = {0};
int units_sold{0};
int units_sold(0);
使用花括号来初始化的形式被称作列表初始化(list initialization)。从C++ 11开始,无论是初始化对象还是为对象赋新值,都可以使用这样一组由花括号括起来的初始值。
将列表初始化应用到内置类型变量时,如果初始值存在丢失信息的风险,编译器会报错。
如果定义变量时没有指定初值,则变量被默认初始化(default initialization),默认值由变量类型、定义变量的位置决定。
对于内置类型而言,如果未显式初始化,其值由定义位置决定。定义在任何函数体之外的变量被初始化为0,定义在函数体内部的内置类型变量不被初始化,其值是未定义的,如果试图拷贝或以其他形式访问将引发错误。
对于类而言,由类自己决定是否允许默认初始化。绝大多数类均支持默认初始化,但也有些类要求每个对象都显式初始化。
为了允许将程序拆分成多个逻辑部分来编写,C++支持分离式编译(separate compilation)机制,从而将程序分割为多个文件,各文件独立编译。
为了支持分离式编译,C++将声明和定义区分开来。声明(declaration)使得名字为程序所知,定义(definition)则负责创建与名字关联的实体。
变量声明规定了变量的类型和名字,这一点和定义相同。但定义还申请了存储空间,也可能为变量赋初始值。
如果想声明一个变量,就在变量名前添加extern关键字。任何包含显式初始化的声明即成为定义,如果给extern关键字标记的变量赋初始值,就抵消了extern的作用,它就不再是声明,而变成定义了。如果在函数体内部初始化一个extern关键字标记的变量,会引发错误。
标识符由字母、数字和下划线组成,且不能以数字开头。标识符长度没有限制,但对大小写敏感。
C++保留了一些名字供语言本身使用,这些名字不能用作标识符。同时,C++为标准库保留了一些名字。用户自定义的标识符中不能连续出现两个下划线,也不能以下划线紧连大写字母开头。定义在函数体之外的标识符不能以下划线开头。
作用域(scope)是程序的一部分,C++中大多数作用域都以花括号分隔。同一个名字在不同的作用域中可能指向不同的实体。名字的有效区域始于名字的声明语句,结束于声明语句所在的作用域末端。
外层作用域中定义或声明的名字,在内层作用域中均可访问。
内层作用域中可以重新定义外层作用域中已有的名字,且优先使用内层作用域中定义的变量。如内层已经覆盖,但又想使用外层作用域中定义的变量,可以使用作用域运算符::来引用。
#include
int reused = 42; // reused具有全局作用域
int main()
{
int unique = 0; // unique具有块作用域
// #1:使用全局变量reused,输出42 0
std::cout << reused << " " << unique << std:endl;
int reused = 0; // 新建局部变量reused,覆盖了全局变量reused
// #2:使用局部变量reused,输出0 0
std::cout << reused << " " << unique << std:endl;
// #3:显式访问全局变量reused,输出42 0
std::cout << ::reused << " " << unique << std:endl;
return 0;
}
复合类型(compound type)是指基于其他类型定义的类型。
引用(reference)类型引用另一种类型,相当于为一个已经存在的对象起了一个别名,所以引用并不是对象。
除了两种例外情况,其他所有引用的类型都要和与之绑定的对象严格匹配:
引用只能绑定在对象上,不能与字面值或某个表达式的计算结果绑定在一起。因为引用不是对象,所以不能定义引用的引用。
通过将声明符写成&d的形式来定义引用类型。定义引用时,程序将引用和初始值绑定在一起,无法令引用重新绑定到另一个对象。因此,引用必须初始化。
定义引用之后,对其进行的所有操作都是在与之绑定的对象上进行的。
int ival = 1024;
int &refVal = ival; // refVal引用ival,即refVal是ival的别名
int &refVal2; // 报错。引用必须初始化
指针是指向另一种类型的复合类型。
相同点:指针和引用都实现了对其他对象的间接访问。
不同点:
指针存放的是某个对象的地址。对象的地址通过取地址符&获取。
指针和引用都能提供对其他对象的间接访问。但引用本身不是对象,一旦定义引用,就无法令其再绑定到其他对象上。指针则没有这种限制,对指针赋值,就是令它指向新的对象。
引用不是对象,所以不存在指向引用的指针。
除了两种例外情况,其他所有指针的类型都要和它指向的对象严格匹配:
指针的值(即地址)应属以下四种状态之一:
如果指针指向了一个对象,则允许使用解引用符*来访问该对象。
空指针不指向任何对象,在试图使用一个指针之前,应先检查其是否为空。
生成空指针的方法:
在条件表达式中,空指针为false,非空指针为true。使用==或!=来比较指针,实际是判断是否指向同一地址。
两个指针相等,有三种可能:
注意,如果一个指针指向某对象,另一指针指向另一个对象的下一地址 ,此时两个指针也可能相等。
void *是一种特殊的指针类型,可以存放任意对象的地址。我们对该地址到底存放了什么类型的对象并不了解,所以不能直接操作它所指的对象。
void *的用途较为固定:
变量的定义包括一个基本数据类型和一组声明符。在一条声明语句中,基本数据类型是固定不变的,但声明符多种多样,所以一条定义语句中可以定义出不同类型的变量。
int i = 1024, *p = &i, &r = i;
注意,*和&仅修饰紧跟在其后的变量。以下代码中,i是指向int类型变量的指针,j是int类型变量。
int *i, j;
引用不是对象,所以没有指向引用的指针。
指针是对象,所以存在对指针的引用。
对于复杂的定义,从右向左解读即可。
int *&r;
r首先是一个引用,它引用的是指向int类型的指针。
如果我们希望某个变量的值不能被改变,就可以用const关键字对变量类型加以限定。任何对const变量赋值的行为都将引发错误。
const对象一旦创建后就不能再改变,所以必须初始化。如果用一个对象a来初始化const类型对象,a本身是不是const都无关紧要,因为我们只是从中取值。
前面反复强调,对象的类型决定其上的操作。与非const类型所能参与的操作相比,const类型只能执行不改变其内容的操作。
当以编译时初始化的方式定义一个const对象时,编译器将在编译过程中将用到该变量的地方都替换成对应的值。譬如以下代码,编译器会找到代码中所有用到bufSize的地方,替换成512。
const int bufSize = 512;
为了执行上述替换,编译器必须知道变量的初始值。默认情况下,const对象被设定为仅在文件内有效。当多个文件中出现同名的const变量时,等同于在不同文件中分别定义了独立的变量。
如果确实需要在不同文件之间共享const变量,则在声明和定义该const变量时,都要添加extern关键字。
我们可以将引用绑定到const对象上,称为对常量的引用(reference to const)。对常量的引用不能用作修改它所绑定的对象。
通常所说的“常量引用”并不准确,严格说,是对const的引用。
3.1小节中提到,引用的类型必须和其所引用对象一致,但有两个例外。const即为第一个例外情况。初始化常量引用时,允许用任意表达式作为初始值,只要该表达式的结果能转换成引用的类型即可。尤其是,常量引用可以绑定非常量的对象、字面值、甚至一般表达式。
int i = 42;
const int &r1 = i; // 正确,const int &可以绑定到普通int对象上
const int &r2 = 42; // 正确,r2是常量引用
const int &r3 = r1 * 2; // 正确。r3是常量引用
int &r4 = r1 * 2; // 错误。r4是一个普通的非常量引用
double dval = 3.14;
const int &ri = dval;
此处ri引用了一个int型的对象,但实际用double类型的对象来初始化。为了保证类型正确,编译器会将代码改为以下形式:
const int temp = dval; // 由双精度浮点数生成一个临时的整型常量
const int &ri = temp; // 让ri绑定这个临时量
可以看到,当一个常量引用被绑定到另一种类型上时,为了保证绑定对应类型,编译器会创建一个临时量对象,使引用绑定到这个临时量对象上。
所谓临时量对象,就是当编译器需要一个空间来暂存表达式求值结果时临时创建的一个未命名的对象。
当ri不是常量时,就应当可以通过ri来改变dval的值。但ri实际是绑定到temp上的,这就会引发错误。因此,非常量引用不支持这种初始化。
如果将一个引用定义为const,只是限制了引用能参与的操作。至于被引用的对象本身是否能被修改,并无限定。
这就类似,我能改,但我就是不改,就是玩儿~
指向常量的指针不能用于改变其所指对象的值。
想存放常量对象的地址,只能使用指向常量的指针。但指向常量的指针也可以存放非常量对象的地址。
指针是对象,所以可以将指针定义为常量。常量指针必须初始化,初始化完成后只能指向这个固定的地址,不能再修改。
顶层const表示指针本身是个常量。底层const表示指针所指的对象是一个常量。
更一般化的表述是,顶层const可以表示任意的对象是常量,底层const则与指针和引用等复合类型的基本类型部分有关。
指针既可以是顶层const,也可以是底层const。
int i = 0;
int *const p1 = &i; // 顶层const
const int ci = 42; // 顶层const
const int *p2 = &ci; // 底层const
const int *const p3 = p2; // 第一个const是底层const,第二个const是顶层const
const int &r = ci; // 底层const
常量表达式是指值不会改变并且在编译过程就能得到计算结果的表达式。判断一个对象或表达式是不是常量表达式也要从这两点进行。
const int max_files = 20; // max_files是常量表达式
cosnt int limit = max_files + 1; // limit是常量表达式
int staff_size = 27; // staff_size不是常量,所以不是常量表达式
const int sz = get_size(); // sz的值在运行时才能确定,所以不是常量表达式
在复杂系统中,很难分辨一个初始值到底是不是常量表达式。C++11标准规定,允许将变量声明为constexpr类型,以便由编译器来验证变量的值是否是一个常量表达式。声明为constexpr的变量一定是一个常量,而且必须用常量表达式初始化。
constexpr int mf = 20; // 20是常量表达式
constexpr int limit = mf + 1; // mf+1是常量表达式
constexpr int sz = size(); // size()是constexpr函数时,才是正确的声明语句
新标准允许定义一种特殊的constexpr函数,这种函数应该足够简单,在编译时就能计算结果。不能使用普通函数作为constexpr变量的初始值,但可以使用constexpr函数去初始化constexpr变量。
如果认定变量是一个常量表达式,就将其声明成constexpr类型。
常量表达式的值需要在编译时就计算出来,因此声明constexpr时用到的数据类型就有所限制。这些类型通常比较简单,值也容易得到。这些类型统称字面值类型。
算术类型、引用类型、指针属于字面值类型。自定义类、IO库、string类型则不属于字面值类型,所以不能被定义成constexpr。其他字面值类型后续介绍。
指针和引用都能定义成constexpr,但初始值受到严格限制。一个constexpr指针的初始值必须是nullptr或0,或是存储在某个固定地址中的对象。
函数体内定义的局部变量通常不存放在固定地址中,因此constexpr指针不能指向这样的变量。定义在所有函数体之外的全局变量地址固定不变,可以用来初始化constexpr指针。此外后面会介绍,允许函数定义一类有效范围超出函数本身的变量,这类变量也有固定地址,constexpr引用可以绑定到此类变量上,constexpr指针也可以指向这样的变量。
前面提到,对于变量类型比较复杂的情况,要从右向左分析。constexpr是个例外,它把对象置为顶层const。因此,如果使用constexpr定义一个指针,限定符constexpr仅对指针有效,与指针所指对象无关。
cosnt int *p = nullptr; // p是一个指向整型常量的指针
constexpr int *q = nullptr; // q是一个指向整数的常量指针
与其他常量指针类似,constexpr指针既可以指向常量,也可以指向非常量。
constexpr int *np = nullptr; // np是一个指向整数的常量指针,其值为空
int j = 0;
constexpr int i = 42; // i的类型是整型常量
// i和j都必须定义在函数体之外
constexpr const int *p = &i; // p是常量指针,指向整形常量i
constexpr int *pl = &j; // p1是常量指针,指向整数j
类型别名就是给某种类型起一个别名。类型别名可以让复杂的类型名变得简明易懂,有助于了解使用该类型的真实目的。类型别名和类型的名字等价,只要是类型的名字能出现的地方,都可以使用类型别名。
有两种方法可用于定义类型别名。
使用关键字typedef即可定义类型别名。typedef作为声明语句中的基本数据类型的一部分出现,这里的声明符可以包含类型修饰,从而可以由基本数据类型构造出复合类型。
typedef double wages; // wages是double的同义词
typedef wages base, *p; // base是double的同义词,p是double *的同义词
这种方法用关键字using作为别名声明的开始,其后紧跟别名和等号,即将等号左侧的名字规定成等号右侧类型的别名。
using SI = Sales_item;
如果在声明语句中使用指代复合类型或常量的类型别名,就会产生意想不到的后果。人们往往会把类型别名替换成它本来的样子,这种理解是错误的。
typedef char *pstring;
const pstring cstr = 0; // cstr是指向char的常量指针
const pstring *ps; // ps是一个指针,它的对象是指向char的常量指针
const是对给定类型的修饰。对于上述第二行代码,应理解为顶层const。即cstr是一个常量,类型为pstring。
编程时经常需要把表达式的值赋给变量,这要求在声明变量时清楚地知道表达式的类型。在复杂系统中,这一点很难做到。C++11中引入了auto类型说明符,让编译器替我们分析表达式所属的类型。auto让编译器通过初始值来推算变量类型,所以auto定义的变量必须有初始值。
auto item = val1 + val2;
使用auto也可以在一条语句中声明多个变量。但由于一条声明语句只能有一个基本数据类型,所以该语句中所有变量的初始基本数据类型必须一致。
auto i = 0, *p = &i; // 正确,i是整型,p是指向整型的指针
auto sz = 0, pi = 3.14; // 错误,sz和pi的类型不一致
编译器会适当地改变结果类型,使其更符合初始化规则。因此编译器推断出的auto类型和初始值的类型未必完全一样。
使用引用作为初始值,真正参与初始化的是引用对象的值。所以编译器以引用对象的类型作为auto的类型。
int i = 0, &r = i;
auto a = r; // r是i的引用,i是int类型,因此a是int类型
auto通常会忽略顶层const,而底层const则会保留下来。
int i = 0;
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是指向整型常量的指针
如果希望推断出的auto类型是顶层const,需要明确指出:
int i = 0;
const int ci = i;
const auto f = ci; // f是const int类型
如果将引用的类型设为auto,初始值中的顶层const会被保留。
int i = 0;
const int ci = i;
auto &g = ci; // g是整型常量的引用
auto &h = 42; // 错误。不能为非常量引用绑定字面值
const auto &j = 42; // 正确。可以为常量引用绑定字面值
在一条语句中定义多个变量,符号&和*只从属于某个声明符,并非基本数据的一部分。所以初始值必须是同一种类型。
int i = 0;
const int ci = i;
auto k = ci, &l = i; // k是整数,l是整型引用
auto &m = ci, *p = &ci; // m是对整型常量的引用,p是指向整型常量的指针
auto &n = i, *p2 = &ci; // 错误。i是int类型,ciconst int
auto会根据表达式推算要定义的变量的类型并对其初始化。但有时我们只希望根据表达式推断出变量的类型,并不想初始化。此时就要用到decltype。
C++11中引入了decltype,其作用是选择并返回操作数的数据类型。在此过程中,编译器分析表达式并得到其类型,但并不实际计算表达式的值。
decltype(f()) sum = x; // sum的类型即为f()的返回类型
decltype不忽略顶层const。如果decltype使用的表达式是一个变量,则decltype返回该变量的类型(包括顶层const和引用在内)。引用从来都是作为其所指对象的同义词出现,只有在decltype这里是例外。
const int ci = 0, &cj = ci;
decltype(ci) x = 0; // x的类型是const int
decltype(cj) y = x; // y的类型是cosnt int &,绑定到x
decltype(cj) z; // 错误。z是一个引用,必须初始化
如果decltype使用的表达式不是一个变量,则decltype返回表达式结果对应的类型。有些表达式会向decltype返回一个引用类型,这通常意味着该表达式的结果对象能作为一条赋值语句的左值。
// decltype的结果可以是引用类型
int i = 42, *p = &i, &r = i;
decltype(r + 0) b; // 正确。加法的结果是int,因此b是一个未经初始化的int类型变量
decltype(*p) c; // 错误。c是int &,必须初始化。
因为r是一个引用,所以decltype®的结果是引用类型。如果我们想让结果类型是r所指的类型,可以将r作为表达式的一部分,这个表达式的结果是一个具体值,而非引用。
如果表达式的内容是解引用操作,则decltype得到引用类型。所以decltype(*p)的结果是int &而非int。
decltype和auto还有一个重要区别,decltype的结果类型和表达式形式密切相关。如果decltype所用的表达式是变量名,加括号和不加括号得到的类型不同。如果decltype使用不加括号的变量,结果是变量的类型。如果给变量加上一层或多层括号,编译器就会将其作为表达式处理。变量是一种可以作为赋值语句左值的特殊表达式,所以decltype会得到引用类型。简而言之,decltype((variable))的结果永远是引用。
int i = 42;
decltype ((i)) d; // 错误。d是int &类型,必须初始化
decltype(i) e; // 正确。e是一个未经初始化的int类型变量