跳转至

第二章 变量和基本类型


基本内置类型

C++定义了一套包括算术类型(arithmetic type)和空类型(void)在内的基本数据类型。其中算术类型包含了字符、整型数、布尔值和浮点数。空类型不对应具体的值,仅用于一些特殊的场合,如可作为函数的返回值。

算术类型

算术类型分为整型和浮点型。

算术类型的尺寸(所占比特数)在不同机器上有所差别。C++标准规定了尺寸的最小值,编译器允许赋予这些类型更大的尺寸。某一类型所占的比特数不同,它所能表示的数据范围也不一样。

算术类型尺寸表格见书本p30。

布尔类型(bool)的取值是true或者false。

浮点型可表示单精度、双精度和扩展精度值。一般来说,类型float和double分别有7和16个有效位,float以1个字(32比特)来表示,double以2个字(64比特)来表示。

带符号类型和无符号类型

除去布尔类型和扩展的字符型之外,其它整型可以划分为带符号的(signed)和无符号的(unsigned)两种。带符号类型可以表示正数、负数或0,无符号类型仅能表示大于等于0的值。

与其他整型不同,字符型被分成了三种:char、signed char和unsigned char。类型char会表现为上述形式中的一种,具体是哪种由编译器决定。

Note

在GCC上测试,char是有符号的。

选择类型的一些经验准则:

  • 当明确知晓数值不可能为负时,选用无符号类型。

  • 使用int执行整数运算,如果数值超过了int的表示范围,选用long long。

  • 在算术表达式中不要使用char或bool。因为char在不同机器上的表现方式不一样。

  • 执行浮点数运算选用double。因为double精度更高,且运算代价和float没有相差无几。

类型转换

对象的类型定义了对象能包含的数据和能参与的运算,其中一种运算被大多数类型支持,就是将对象从一种给定的类型转换(convert)为另一种相关类型。

当在程序的某处我们使用了一种类型而其实对象应该取另一种类型时,程序会自动进行类型转换。

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

  • 当把非bool的算术值赋给bool类型时,初始值为0则结果为false,否则结果为true。

  • 当把bool值赋给非bool类型时,初始值为false则结果为0,初始值为true则结果为1。

  • 当把一个浮点数赋给整数类型时,结果值将仅保留浮点数中小数点之前的部分。

  • 当把一个整数值赋给浮点类型时,小数部分记为0。如果该整数所占的空间超过了浮点类型的容量,精度有可能损失。

  • 当我们赋给无符号类型一个超出它表示范围的值时,结果是初始值对无符号类型表示数值总数取模后的余数。

  • 当我们赋给带符号类型一个超出它表示范围的值时,结果是未定义的(undefined)。此时,程序可能继续工作、可能崩溃、也可能生成垃圾数据。

测试代码

Warning

切勿混用带符号类型和无符号类型(比如拿有符号数和无符号数做比较)。如果表达式里既有带符号类型又有无符号类型,当带符号类型取值为负时会出现异常结果,这是因为带符号数会自动地转换成无符号数。

字面值常量

一个形如42的值被称作字面值常量(literal),这样的值一望便知。每个字面值常量都对应一种数据类型,字面值常量的形式和值决定了它的数据类型。

整型和浮点型字面值

我们可以将整型字面值写作十进制数、八进制数或十六进制数的形式。以0开头的整数代表八进制数,以0x或0X开头的代表十六进制数。例如,我们能用下面的任意一种形式来表示数值20:

20 /* 十进制 */    024 /* 八进制 */    0x14 /* 十六进制 */

十进制字面值的类型是int、long和long long中尺寸最小的那个。八进制和十六进制字面值的类型是能容纳其数值的int、unsigned int、long、unsigned long、long long和unsigned long long中尺寸最小者。

浮点型字面值表现为一个小数或以科学计数法表示的指数,其指数部分用E或e标识:

3.14159 3.14159E0    0.    0e0    .001

默认的,浮点型字面值是一个double。

Note

GCC下,像20这样的十进制整数字面值,类型是int

字符和字符串字面值

由单引号括起来的一个字符称为char型字面值,双引号括起来的零个或多个字符则构成字符串字面值。

'a'    // 字符字面值

"a"    // 字符串字面值

字符串字面值的类型实际上是由常量字符构成的数组(array)。编译器在每个字符串的结尾处添加一个空字符('\0'),因此,字符串字面值的实际长度要比它的内容多1。

转义序列

有两类字符程序员不能直接使用:一类是不可打印(nonprintable)字符,如退格或其他控制字符;另一类是有特殊含义的字符,如引号、问号、反斜线。这些情况下需要用到转义序列(escape sequence),转义序列以反斜线作为开始。

转义序列见书本p36。

指定字面值的类型

通过添加一些前缀和后缀,可以改变整型、浮点型和字符型字面值的默认类型。

这些前缀和后缀见书本p37。

布尔字面值和指针字面值

true和false是布尔类型的字面值。

变量

变量提供一个具名、可供程序操作的存储空间。C++中的每个变量都有其数据类型,数据类型决定着变量所占内存空间的大小和布局方式、该空间能存储的值的范围,以及变量能参与的运算。

变量定义

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

int sum = 0, value = 0;

术语:何为对象?

通常情况下,对象是指一块能存储数据并具有某种类型的内存空间。

初始值

当对象在创建时获得了一个特定的值,我们说这个对象被初始化(initialized)了。

Warning

初始化不是赋值,初始化的含义是创建变量时赋予一个初始值,而赋值的含义是把对象当前值擦除,而以一个新值替代。

列表初始化

要想定义一个名为units_sold的int变量并初始化为0,以下4条语句都可以做到这一点:

int units_sold = 0;
int units_sold = {0};
int units_sold(0);
int units_sold{0};

作为C++11新标准的一部分,用花括号来初始化变量得到了全面应用。这种初始化的形式被称为列表初始化(list initialization)。

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

my note: 在我的GCC 4.8.5下面,这种情况会报warning。

默认初始化

如果定义变量时没有指定初值,则变量被默认初始化(default initialized)。

如果是内置类型的变量未被显示初始化,它的值由定义的位置决定。定义于函数体之外的变量被初始化为0。定义在函数体内部的内置类型变量将不被初始化(uninitialized)。一个未被初始化的内置类型变量的值是未定义的。

每个类各自决定其初始化对象的方式。

Tip

建议初始化每一个内置类型的变量。

变量声明和定义的关系

为了允许把程序拆分成多个逻辑部分来编写,C++语言支持分离式编译(separate compilation)机制,该机制允许将程序分割为若干个文件,每个文件可被独立编译。

为了支持分离式编译,C++语言将声明和定义区分开来。声明(declaration)使得名字为程序所知,一个文件如果想使用别处定义的名字则必须包含对那个名字的声明。而定义负责创建与名字关联的实体。

如果想声明一个变量而非定义它,就在变量名前添加关键字extern,而且不要显示初始化变量:

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

任何包含了显式初始化的声明即成为定义。

Note

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

标识符

C++的标识符(identifier)由字母、数字和下画线组成,其中必须以字母或下画线开头。标识符的长度没有限制,但是对大小写敏感。

C++语言保留了一些名字供语言本身使用,这些名字不能被用作标识符。见书本p43。

同时,C++也为标准库保留了一些名字。用户自定义的标识符中不能连续出现两个下画线,也不能以下画线紧连大写字母开头。此外,定义在函数体外的标识符不能以下画线开头。

名字的作用域

不论是在程序的什么位置,使用到的每个名字都会指向一个特定的实体:变量、函数、类型等。

作用域(scope)是程序的一部分,在其中名字有其特定的含义。C++语言中大多数作用域都以花括号分隔。

同一个名字在不同的作用域中可能指向不同的实体。名字的有效区域始于名字的声明语句,以声明语句所在的作用域末端为结束。

名字main定义于所有花括号之外,它和其他大多数定义在函数体之外的名字一样拥有全局作用域(global scope)。一旦声明后,全局作用域内的名字在整个程序的范围内都可使用。

my note: 在花括号内定义的变量拥有块作用域。for语句内定义的名字,只能在for语句之内访问。

嵌套的作用域

作用域能彼此包含,被包含的作用域称为内层作用域(inner scope),包含着别的作用域的作用域称为外层作用域(outer scope)。

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

复合类型

复合类型(compound type)是指基于其他类型定义的类型。

引用

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

int ival = 1024;
int &refVal = ival;    // refVal指向ival(是ival的另一个名字)

定义引用时,程序把引用和它的初始值绑定(bind)在一起,而不是将初始值拷贝给引用。一旦初始化完成,引用将和它的初始值对象一直绑定在一起。因为无法令引用重新绑定到另外一个对象,因此引用必须初始化。

Note

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

引用只能绑定在对象上,而不能与字面值或某个表达式的计算结果绑定在一起。

指针

指针(pointer)是“指向”另外一种类型的复合类型。指针本身就是一个对象,允许对指针赋值和拷贝。和其他内置类型一样,在块作用域内定义的指针如果没有被初始化,也将拥有一个不确定的值。

定义指针类型的方法将声明符写成*d的形式,其中d是变量名。如果在一条语句中定义了几个指针变量,每个变量的前面都必须有符号*。

int *p1, *p2;    // p1和p2都是指向int型对象的指针

获取对象的地址

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

int ival = 42;
int *p = &ival; // p存放变量ival的地址,或者说p是指向变量ival的指针

因为引用不是对象,没有实际地址,所以不能定义指向引用的指针。

通常,所有指针的类型都要和它所指的对象严格匹配。

double dval;
double *pd = &dval;    // 正确,初始值是double型对象的地址

int *pi = pd;          // 错误,指针pi的类型和pd的类型不匹配

指针值

指针的值(即地址)应属下列4种状态之一:

  1. 指向一个对象。

  2. 指向紧邻对象所占用空间的下一个位置。

  3. 空指针,意味着指针没有指向任何对象。

  4. 无效指针,也就是上述情况之外的其他值。

利用指针访问对象

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

int ival = 42;
int *p = &ival;
cout << *p;    // 由符号*得到指针p所指的对象,输出42

Warning

解引用操作仅适用于那些确实指向了某个对象的有效指针。否则其行为是未定义的。

空指针

空指针(null pointer)不指向任何对象。以下列出几个生成空指针的方法:

int *p1 = nullptr;    // 等价于 int *p1 = 0
int *p2 = 0;
// 需要首先#include <cstdlib>
int *p3 = NULL;

void*指针

void*是一种特殊的指针类型,可用于存放任意对象的地址。

利用void*指针能做的事儿比较有限:拿它和别的指针比较、作为函数的输入或输出,或者赋给另外一个void*指针。不能直接操作void*所指的对象。

理解复合类型的声明

变量的定义包括一个基本数据类型(base type)和一组声明符。在同一条定义语句中,虽然基本数据类型只有一个,但是声明符的形式却可以不同:

// i是一个int型的数,p是一个指向int型的指针,r是一个int型的引用
int i = 1024, *p = &i, &r = i;

Tip

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

面对一条比较复杂的指针或引用的声明语句时,从右向左阅读有助于弄清楚它的真实含义。

const限定符

有时我们希望定义这样一种变量,它的值不能被改变。为了满足这一要求,可以用关键字const对变量的类型加以限定:

const int bufSize = 512;

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

因为const对象一旦创建后其值就不能再改变,所以const对象必须初始化。

默认情况下,const对象仅在文件内有效

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

const int bufSize = 512;

编译器将在编译过程中把用到该变量的地方都替换成对应的值。

为了支持这一用法,同时避免对同一变量的重复定义,默认情况下,const对象被设定为仅在文件内有效。当多个文件中出现了同名的const变量时,其实等同于在不同文件中分别定义了独立的变量。

Note

如果想在多个文件之间共享const对象,必须在变量的定义之前添加extern关键字。

const的引用

把引用绑定到const对象上,称之为对常量的引用(reference to const)。

const int a = 1024;
const int &r = a;

对常量的引用不能修改它所绑定的对象的值。

初始化和对const的引用

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

int i = 42;
const int &r1 = i;    // 允许将const int&绑定到一个普通int对象上
const int &r2 = 42;   // 正确:r1是一个常量引用
const int &r3 = r1 * 2; // 正确:r3是一个常量引用
int &r4 = r1 * 2;     // 错误:r4是一个普通的非常量引用

对const的引用可能引用一个并非const的对象

必须认识到,常量引用仅对引用可参与的操作做出了限定,对引用的对象本身是不是一个常量未作限定。

指针和const

指向常量的指针(pointer to const)不能改变其所指对象的值。想要存放常量对象的地址,只能使用指向常量的指针:

const double pi = 3.14;
const double *p = &pi;

指针的类型必须与其所指对象的类型一致,但是有两个例外。第一种例外情况是允许令一个指向常量的指针指向一个非常量对象:

double dval = 3.14;
cptr = &dval;    // 正确,但是不能通过cptr改变dval的值

const指针

指针是一个对象,可以把它定义成const的,叫常量指针(const pointer)。把*放在const关键字之前用以说明指针是一个常量:

int n = 0;
int *const p = &n;

顶层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 ci = 42;    // 不能改变ci的值,ci是一个顶层const
const int *p2 = &ci;   // 允许改变p2的值,p2是一个底层const
const int *const p3 = p2; // 靠右的const是顶层const,靠左的是底层const
const int &r = ci;     // 用于声明引用的const都是底层const

constexpr和常量表达式

常量表达式(const expression)是指值不会改变,且在编译过程中就能得到计算结果的表达式。

这些都是常量表达式:

  • 字面值

  • 用常量表达式初始化的const对象

constexpr变量

用const定义的变量并不一定是常量表达式,因此要换一种方法定义常量表达式。

C++11标准提供了constexpr关键字,让编译器验证变量的值是否是一个常量表达式。声明为constexpr的变量一定是一个常量,而且必须用常量表达式初始化:

constexpr int mf = 20;    // 20是常量表达式
constexpr int limit = mf + 1;    // mf + 1是常量表达式
constexpr int sz = size();    // 只有当size是一个constexpr函数时,才是一条正确的声明语句

字面值类型

在编译时就能得到计算,类型比较简单,值也显而易见的类型,叫字面值类型(literal type)。

算术类型,引用,指针都属于字面值类型。

尽管指针和引用都能定义成constexpr,但它们的初始值却受到严格限制。一个constexpr指针的初始值必须是nullptr或者0,或者是存储于某个固定地址中的对象。

指针和constexpr

如果在constexpr声明中定义了一个指针,那么它只对指针有效,与指针所指的对象无关:

const int *p = nullptr;      // p是一个指向常量的指针
constexpr int *p2 = nullptr; // p2是一个常量指针

与其他常量指针类似,constexpr指针既可以指向常量也可以指向一个非常量:

int j = 0;
constexpr int i = 42;
// i和j都必须定义在函数体外
constexpr const int *p = &i;    // p是常量指针,指向整型常量i
constexpr int *p1 = &j;         // p1是常量指针,指向整数j

处理类型

随着程序越来越复杂,程序中用到的类型也越来越复杂,这种复杂性体现在两个方面。一是一些类难于“拼写”。二是有时候根本搞不清到底需要什么类型,需要从程序的上下文中寻求帮助。

类型别名

类型别名(type alias)是一个名字,它是某种类型的同义词。使用类型别名可以让复杂的名字变得简单,有助于程序员清楚地知道使用该类型的真实目的。

传统的定义类型别名的方法是使用typedef:

typedef double wages;    // wages是double的同义词

新标准规定了一种新的方法,使用别名声明(alias declaration)来定义类型的别名:

using SI = Sales_item;    // SI是Sales_item的别名

这种方法使用关键字using作为别名声明的开始,其后紧跟别名和等号,其作用是把等号左侧的名字规定成等号右侧类型的别名。

auto类型说明符

C++11新标准引入了auto类型说明符,用它让编译器替我们去分析表达式所属的类型。

显然,auto定义的变量必须有初始值:

// 由val1和val2相加的结果可以推断出item的类型
auto item = val1 + val2;

使用auto也能在一条语句中声明多个变量。因为一条声明语句只能有一个基本数据类型,所以该语句中所有变量的初始基本数据类型都必须一样:

auto i = 0, *p = &i; // 正确,i是整数,p是整型指针
auto sz = 0, pi = 3.14; // 错误,sz和pi的类型不一致

复合类型、常量和auto

编译器推断出来的auto类型有时候和初始值的类型并不完全一样,编译器会适当地改变结果类型使其更符合初始化规则。

首先,当引用被用作初始值时,真正参与初始化的其实是引用对象的值。此时编译器以引用对象的类型作为auto的类型:

int i = 0, &r = i;
auto a = r; // a是一个int

其次,auto一般会忽略掉顶层const,同时底层const则会保留下来:

const int ci = i, &cr = ci;
auto b = ci; // b是一个int,ci的顶层const被忽略
auto c = cr; // c是一个int,cr是ci的别名,其顶层const被忽略
auto d = &i; // d是一个int*
auto e = &ci; // e是一个const int*,对常量对象取地址是一种底层const

如果希望推断出来的auto类型是一个顶层const,需要明确指出:

const auto f = ci; // ci推演成int,f是const int

还可以将引用的类型设为auto,此时原来的初始化规则仍然适用:

auto &q = ci; // q是一个整型常量引用,绑定到ci
auto &h = 42; // 错误:不能为非常量引用绑定到字面值
const auto &j = 42; // 正确,可以为常量引用绑定字面值

decltype类型指示符

C++新标准引入了第二种类型说明符decltype,它的作用是选择并返回操作数的数据类型。在此过程中,编译器分析表达式并得到它的类型,却不实际计算表达式的值:

decltype(f()) sum = x; // sum的类型就是函数f的返回类型

编译器并不实际调用f,而是使用当调用发生时f的返回值的类型作为sum的类型。

如果decltype使用的表达式是一个变量,则decltype返回该变量的类型(包括顶层const和引用在内):

const int ci = 0, &cj = ci;
decltype(ci) x = 0; // x的类型是const int
decltype(cj) y = x; // y的类型是const int&, y绑定到x

decltype和引用

如果decltype使用的表达式不是一个变量,则decltype返回表达式结果对应的类型。如果表达式向decltype返回一个引用类型,一般来说,意味着该表达式的结果对象能作为一条赋值语句的左值:

// decltype的结果可以是引用类型
int i = 42, *p = &i, &r = i;
decltype(r + 0) b; // 正确,加法的结果是int,因此b是一个int
decltype(*p) c; // 错误,c是int&,必须初始化

如果表达式的内容是解引用操作,则decltype将得到引用类型。

有一种情况需要特别注意:对于decltype所用的表达式来说,如果变量名加上了一对括号,编译器就会把它当成一个表达式。变量是一种可以作为赋值语句左值的特殊表达式,所以这样的decltype就会得到引用类型:

// decltype的表达式如果是加上了括号的变量,结果是引用
decltype((i)) d; // 错误,d是int&,必须初始化
decltype(i) e; // 正确,e是一个int。

Warning

切记,decltype((variable))的结果永远是引用,而decltype(variable)结果只有当variable本身就是一个引用时才是引用。

自定义数据结构

从最基本的层面理解,数据结构是把一组相关的数据元素组织起来然后使用它们的策略和方法。

C++语言允许用户以类的形式自定义数据类型,而库类型string、istream、ostream等也都是以类的形式定义的,就像第1章的Sales_item类型一样。

定义Sales_data类型

Sales_data初步定义如下:

struct Sales_data {
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};

我们的类以关键字struct开始,紧跟着类名和类体(其中类体部分可为空)。类体由花括号包围形成了一个新的作用域。

类体右侧的表示结束的花括号后必须写一个分号。

类数据成员

类体定义类的成员,我们的类只有数据成员(data member)。类的数据成员定义了类的对象的具体内容,每个对象都有自己的一份数据成员拷贝。

C++11新标准规定,可以为数据成员提供一个类内初始值(in-class initializer)。创建对象时,类内初始值将用于初始化数据成员。没有初始值的成员将被默认初始化。

编写自己的头文件

为了确保各个文件中类的定义一致,类通常被定义在头文件中,而且类所在头文件的名字应与类的名字一样。

头文件通常包含那些只能被定义一次的实体,如类、const和constexpr变量等。

预处理器概述

确保头文件多次包含仍能安全工作的常用技术是预处理器(preprocessor)。

C++程序使用头文件保护符(header guard)来避免头文件重复包含。

#ifndef SALES_DATA_H
#define SALES_DATA_H

struct Sales_data {
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};

#endif