第二章 变量与基本类型

86 阅读24分钟

任何常用的编程语言都有一组公共的语法特性,不同语言仅在细节上有区别。理解语法特性的实现细节是学习并掌握这门编程语言的第一步。最基本的特性包括:

  • 整型、字符型等内置类型;
  • 变量,用于为对象命名;
  • 表达式和语句,用于操纵具体值;
  • ifwhile 等控制语句;
  • 函数,用于定义可供随时调用的计算单元;

大多数编程语言通过以下两种方式进一步补充基本特性:

  1. 自定义数据类型
  2. 将有用的功能封装成库函数

与 Python 这种运行时检查数据类型的语言不同,C++ 是静态数据类型语言,类型检查在编译时,编译器需要知道每个变量的数据类型。C++ 提供了类这一语法特性来让程序员自定义数据类型。数据类型是程序的基础,它告诉了我们数据的意义以及可以执行的操作。

基本内置类型

C++ 定义了一套包含算术类型空类型在内的基本数据类型。算术类型包括:字符、整型数、布尔值、浮点数。空类型仅用于特殊场合,比如无返回值函数的返回值类型。

算术类型

算术类型分两类:整型(包括字符和布尔值)和浮点型。算术类型的大小在不同机器上有所差别,C++ 规定了最小值,同时允许编译器赋予这些类型更大的尺寸。

arithmetic-type.png

计算机中,可寻址的最小内存块称为字节,存储的基本单元称为,通常由几个字节构成。大多数机器的字节由 8 个比特构成,字则有 4 或 8 个字节构成。大多数计算机将内存中的每个字节与一个数字关联起来,称为地址

为了赋予内存中某个地址明确的含义,必须知道存储在该地址的数据类型,类型决定了数据所占的比特数以及如何解释这些比特的内容。通常,float 由 32 个比特表示,double 由 64 个比特表示,long double 由 96 或 128 个比特表示。

除了布尔型和扩展字符型之外,其它整型可分为有符号的无符号的两种。有符号类型可以表示正数、负数、0;无符号类型只能表示大于等于 0 的值。类型 shortintlonglong long 前面添加 unsigned 就是相应的无符号类型。类型 unsigned int 可以简写为 unsigned。字符型分为三类:charsigned charunsigned char。虽然有三种字符型,但是字符的表现形式只有两种:有符号的、无符号的。char 实际上会表现为其中一种,具体是哪种由编译器决定。C++ 没有规定带符号类型应该如何表示,只约定了表示范围内正值和负值的量应该平衡。大多数现代计算机将 signed char 的范围定为 -128 到 127。

选择类型的经验:

  • 明确数值不可能为负时,选择无符号类型。
  • 使用 int 执行整数运算,实际应用中,short 一般太小,long 一般和 int 尺寸相同,如果数值超出了 int,选用 long long
  • 算术表达式中不要出现 charbool,只在存放字符和布尔值时才使用它们。因为 char 是否有符号依赖于机器。如果要使用较小的整数,可以明确指定 signed charunsigned char
  • 浮点数运算一般选用 doublefloat 通常精度不够,floatdouble 的计算代价相差无几;long double 精度一般没有必要,而且运行时消耗过多。

类型转换

大多数类型都支持类型转换这种运算。当程序中应该使用某种类型的位置使用了另一种类型时,会自动进行类型转换。

当某种类型的对象强行赋给另一种类型的值时,类型所能表示的值的范围决定了转换的过程:

  • 非布尔型算术值赋给布尔型时,若为 0 则结果为 false,否则为 true
  • 布尔值赋给非布尔型时,false 的结果为 0,true 的结果为 1;
  • 浮点数赋给整数类型时,只保留整数部分;
  • 整数值赋给浮点型时,小数部分补 0,整数过大则丢失精度;
  • 赋给无符号型一个超过范围的值时,会得到取模后的余数;
  • 赋给有符号型一个超出范围的值时,结果不确定(undefined),程序可能继续工作、可能崩溃、可能产生垃圾数据;

无法预知的行为源于编译器无须(有时是不能)检测的错误。即使编译通过,如果执行了一条未定义的表达式,仍有可能产生错误。程序应尽量避免依赖实现环境的行为。比如,把 int 的尺寸看作确定不变的已知值,那么程序将是不可移植的(nonportable)。当程序移植到别的机器上,依赖于实现环境的程序可能产生错误。

当程序中某处使用了算术类型的值,但本该是另一种类型的值时,也会进行上述类型转换,比如条件判断时。切勿混用有符号类型和无符号类型,无符号数和有符号数参与算术运算时,会互相转换;无符号数之间运算溢出时,也会模回原始范围内。

字面量(literal)

整数字面量可以有十进制、八进制(0 开头)、十六进制(0x/0X 开头)的形式。整数字面量的数据类型由它的值和符号决定,默认情况下,十进制有符号,八进制和十六进制可以有符号也可以无符号。十进制字面量的类型是 intlonglong long 中能容纳该值的尺寸最小的那个;八进制和十六进制的字面量的类型是 intunsigned intlongunsigned longlong longunsigned long long 中能容纳该值的尺寸最小的那个。如果字面量对于最大的数据类型都溢出,则将产生错误。类型 short 没有相应的字面量。严格来讲,十进制字面量不会是负数,-42 开头的负号将作为运算符,而不是字面量的一部分。浮点数字面量可以小数或科学计数法表示,其中指数部分用 Ee 标识。浮点数字面量默认是 double 类型。

字符串型字面量实际上是由常量字符构成的数组。编译器在每个字符串结尾添加了一个空字符 '\0',因此字符串字面量的实际长度比它的内容多 1。如果两个字符串字面量之间仅有空格、缩进、换行,那么它们实际上是一个字符串,这允许我们分行书写一个较长的字符串字面量。

'a'; // 单引号,char 字符
"a"; // 双引号,字符串
"a" "b"; // 等价于 "ab";

有两类字符无法直接使用:

  1. 不可打印的字符;
  2. 在 C++ 语言中有特殊含义的字符;

需要转义字符来表示,C++ 规定的转义字符包括:

escape.png

此外,还可以使用八进制和十六进制数字来指定字符。\ 后紧跟不超过 3 个八进制数字,或是 \x 后紧跟一个或多个十六进制数字,都可以表示数字对应的字符。如果所表示的数字超出了 char 型数据的范围,则可能会报错,一般需要补充前缀来指定类型。

使用前缀、后缀可以为字面量指定类型。

literal.png

整数字面量可以分别指定是否有符号以及所占用的空间。后缀有 U,表明字面量是无符号的;后缀有 L,表明字面量的尺寸至少和 long 一样;后缀有 LL,表明字面量的尺寸至少和 long long 一样。

布尔字面量有 truefalse。指针字面量有 nullptr

变量

变量提供了一个具名的、可供程序操作的存储空间,变量的数据类型决定了变量所占空间的大小、布局方式、所存储的值的范围以及能参与的运算,在 C++ 中,变量和对象可以互换使用,对象就是一块能存储数据并具有某种类型的内存空间。

变量定义

变量定义由类型说明符、变量名列表、初始值构成。

int sum = 0, value, units_sold = 0;

用于对变量初始化的值可以是任意复杂的表达式,对象的名字在定义后就可以立即使用,即使在同一个定义语句中。

double price = 109.99, discount = price * 0.16;

C++ 中,初始化和赋值是两个完全不同的操作,初始化是在创建变量时赋予一个初始值,而赋值是用一个新值代替对象的当前值。

使用花括号 {} 初始化变量称为列表初始化。列表初始化内置类型时,如果存在信息丢失的风险,比如小数部分丢失、数值越界等,编译器将会报错。

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

若定义变量时没有指定初值,则变量被默认初始化,默认初始化的行为依赖于变量类型和变量定义的位置。内置类型变量,如果在任何函数体外定义,默认值为 0;如果在函数体内定义,则不被初始化,值是未定义的,复制或以其它方式访问该值将产生错误。对于类,是否允许默认初始值由类自身决定。

未初始化的变量可能会导致程序崩溃,也可能允许程序执行但产生错误结果,编译器未被要求检查这种错误,因此最好初始化每个内置类型变量。

变量声明与定义的关系

C++ 支持分离式编译,允许将程序分割为若干文件,并单独编译。C++ 将声明和定义区分开,一个文件通过声明来使用别处定义的名字,定义负责创建与名字关联的实体。变量声明规定了变量的类型和名字,变量定义还申请了存储空间,还可能赋初始值。

extern int i; // 声明变量

包含了显式初始化的声明将会变成定义,函数体内部初始化一个由 extern 关键字标记的变量将会引发错误。

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

多个文件中使用同一变量时,能且只能在一个文件中定义,其它使用该变量的文件必须对其声明,而绝不能重复定义。

标识符

C++ 的标识符由字母、数字、下划线组成,不能以数字开头,对大小写敏感。C++ 有一些保留字供语言本身使用。

keywords.png

C++ 为标准库预留了一些名字。自定义标识符不能连续出现两个下划线,不能以下划线紧连大写字母开头,函数体外的标识符不能以下划线开头。

变量命名按如下规范可提升程序的可读性:

  • 标识符能体现实际含义。
  • 变量名一般用小写字母。
  • 自定义类名一般以大写字母开头。
  • 多个单词构成的标识符,单词间须明显区分,比如下划线、驼峰等。

名字的作用域

程序中的每个名字都会指向一个特定的实体:变量、函数、类型等。同一个名字出现在不同位置将指向不同实体。作用域是程序的一部分,其中的名字有其特定的含义,C++ 中大多数作用域都以花括号分隔。名字的有效区域是从它的声明语句开始,到所在作用域末端结束。作用域有全局作用域块作用域(也称局部作用域)等。

变量应在第一次使用时再定义,便于找到变量定义,也可以合理地赋予初始值。

互相嵌套的作用域分为外层作用域内层作用域。一旦声明了某个名字,所嵌套的所有作用域都能访问该名字。而且内层作用域中可以重新定义外层作用域中已有的名字。

如果函数可能使用全局变量,则不应再定义同名的局部变量。

复合类型

基于其它类型定义的类型称为复合类型,比如:引用和指针。一条声明语句由一个基数据类型和紧随其后的一个声明符列表组成,列表中的声明符的类型修饰符可以各不相同。每个声明符命名了一个变量,并指定该变量为与基数据类型有关的某种类型。

引用

这里的引用指的是左值引用

引用并非对象,引用只是为已经存在的对象起了另外一个名字。引用类型通过 & 符来定义。

int ival = 1024;
int &refVal = ival, i2 = refVal; // refVal 引用了整型变量 ival

对引用的所有操作(读、写)都是对与之绑定的对象的操作。

  • 引用必须初始化,初始化时,引用和初始值对象绑定在一起,后续无法再绑定其它对象。
  • 由于引用本身不是对象,因此不能定义引用的引用。
  • 除了一些特殊情况之外,其它所有引用的类型都必须和与之绑定的对象严格匹配。
  • 除了一些特殊情况之外,引用只能绑定在对象上,不能与字面量或表达式的结果绑定。

指针

指针本身也是一个对象,允许对其自身赋值和拷贝,指针在其生命周期中可以指向不同的对象,指针可以没有初始化,块作用域中定义的未初始化的指针的值是不确定的。指针存放的是某个对象的地址,定义指针时用 * 符来修饰,指向某个对象的指针可通过取地址符 & 来获取。

  • 引用不是对象,没有实际地址,不能定义指向引用的指针。
  • 除了特殊情况之外,其它所有指针都要和它所指向的对象类型严格匹配。
  • 不允许将 int 变量直接赋给指针。
double d1, *dp = &d1; // dp 是指向 double 型数据的指针

指针的值(地址)有 4 种状态:

  1. 指向一个对象。
  2. 指向紧邻对象所占空间的下一个位置。
  3. 空指针,即指针没有指向任何对象。
  4. 无效指针。

拷贝或以其它方式访问无效指针的值将会引发错误。编译器不负责检查这种错误,因此程序员必须清楚任意给定的指针是否有效。第 2、3 种指针可能没有指向任何对象,访问它们指向的对象,后果无法预计。

可以使用解引用符 * 访问指针所指向的对象,并对其读写:

int val = 42, *ip = &val;
std::cout << *ip << std::endl;
*ip = 10;
std::cout << val << std::endl;
int *ip1 = ip;
std::cout << *ip1 << std::endl;
ip = 0;

解引用操作仅适用于那些指向了某个对象的有效指针。

&* 符号的意义取决于上下文,可以组成复合类型,也可以作为运算符。

空指针不指向任何对象,使用指针之前可以先检查是否为空。

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

nullptr 是一种特殊的字面量,可以转换为任意其它的指针类型。NULL 是一个预处理变量,在头文件 cstdlib 中定义,它的值为 0。最好使用 nullptr,而不是 NULL

预处理器在编译之前运行。

未初始化的指针后果无法预计,因此应该初始化所有指针。尽量在定义了对象之后再定义指向它的指针,或者初始化为 nullptr 或 0。

有效指针在条件表达式中,如果指针的值是 0,条件取 false,任何非 0 的指针对应的条件都是 true。类型相同的合法指针可以使用 ==!= 进行比较,如果指针存放的地址相同,则它们相等,反之不相等。使用无效指针作为条件或进行比较,后果将不可预计。

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

  • 和其它指针比较
  • 作为函数的输入或输出
  • 赋给另一个 void* 指针

由于不知道 void* 指针所指变量的类型,因此不能直接操作 void* 指针所指的对象。

理解复合类型的声明

一条变量声明语句中可以包含多种形式的声明符,类型修饰符*&)是声明符的一部分。一个声明符中可以包含多个修饰符,按照逻辑关系详加解释即可。

int ival = 1024;
int *pi = &ival; // 指向 ival 的指针
int **ppi = &pi; // 指向 ival 的指针 pi 的指针
int *&r = pi; // 指向 ival 的指针 pi 的引用

引用不是对象,因此不存在指向引用的指针。但指针是对象,可以有指针的引用。

从右往左阅读有助于理解其真实含义。

const 限定符

const 对象必须初始化,初始值可以是任意复杂的表达式,而且只读不能写。

const int bufSize = 512;

如果用字面量初始化 const 对象,编译器会在编译过程中把用到该变量的地方替换成相应的值。默认情况下,const 对象设定为仅对文件内有效。如果需要在不同文件中共享某个不以常量表达式初始化的 const 对象,可以在声明和定义处使用 extern

// file.cpp
extern const int bufSize = fcn();

// file.h
extern const int bufSize;

const 的引用

const 对象的引用称为对常量的引用(reference to const)。对常量的引用的类型必须是 const,而且不能通过引用修改 const 对象。

const int ci = 1024;
const int &r1 = ci;

初始化对常量的引用时,允许使用任意表达式作为初始值,只要能转换为引用的类型即可,比如:非常量的对象或字面量、甚至是一般表达式。对常量的引用可以引用非常量的同类型对象,只是不能修改该对象。

int i = 42;
const int &r1 = i;
const int &r2 = 42;
const int &r3 = r1 * 2;

编译器会将代码:

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

转为:

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

也就是说,程序会创建一个临时量对象(简称临时量)——一个用于暂存表达式计算结果的未命名对象。非 const 的引用只能引用同类型对象的原因就在于,可能使用该引用修改临时量,而不是已知对象。

指针与 const

只有指向常量的指针(pointer to const)才能存放常量的地址。指向常量的指针可以指向非常量的同类型对象,但不能指向不同类型的对象。不能通过指向常量的指针修改所指的对象。

  • 指向常量的指针可以不初始化,指针的值也可以变化。
const double d = 3.14;
const double *pd;
pd = &d;
double d1 = 6.23;
pd = &d1;

指针本身也可以是常量,称为常量指针(const pointer)。常量指针必须初始化,而且指针的值(所存的地址)无法改变。常量指针的定义用 *const 来修饰。

int i = 10;
int *const cpi = &i;
*cpi = 12;
double d = 3.14;
const double *const cpd = &d, d2;

能否通过常量指针修改对象取决于声明语句开头的类型说明,而不是后面的类型修饰符。

顶层 const

顶层 const(top-level const)表示任意对象是常量,底层 const(low-level const)表示复合类型对象的基本类型是常量。

顶层 const 限制了对象自身的读写:初始化后不允许写入。底层 const 限制了对象的类型:底层 const 的对象不能赋值给非底层 const 的对象。

constexpr 和常量表达式

值不会改变而且在编译过程中就能得到计算结果的表达式称为常量表达式,比如:字面量、用常量表达式初始化的 const 对象。一个对象或表达式是否是常量表达式,由它的数据类型和初始值共同决定。

constexpr 类型的变量一定是一个常量,而且必须用常量表达式初始化,编译器将会验证该变量是否是一个常量表达式。普通函数的返回值不能作为 constexpr 变量的初始值,但 constexpr 函数可以。

声明 constexpr 时只能用字面量类型(literal type),包括:算术类型、引用、指针等。constexpr 类型指针的初始值只能是 nullptr、0 或指向存储于某个固定地址的对象。

除了特殊情况之外,函数内定义的变量一般不会存储于固定地址中。而定义在所有函数体外的对象,它们的地址固定不变,可用于初始化 constexpr 指针。constexpr 指针是一种常量指针。

constexpr int *np = nullptr;
// i, j 定义在所有函数之外
int j = 10;
constexpr int i = 20;
constexpr const int *pi = &i;
constexpr int *pj = &j;

处理类型

类型别名

类型别名(type alias)可以让复杂的类型名字变得简洁直观。类型别名和原类型名等价,只要原类型名能出现的地方,就能使用类型别名。定义类型别名:

  1. 可以使用 typedef 声明语句,它由一个已知类型和紧跟着的一个可能包含类型修饰符的声明符列表构成。

    typedef double wages; // wages 是类型 double 的别名
    typedef wages base, *pWages; // base 是类型 double 的别名,pWages 是类型 double* 的别名
    
    wages hourly, weekly;
    
  2. 也可以使用 using别名声明(alias declaration)。

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

借助类型修饰符可以为复合类型起别名,此时别名指代的是整个复合类型。

typedef char *pChar;
const pChar pc = 0; // pc 是指向 char 的常量指针
const pChar *ppc; // ppc 是一个指针,它指向的对象是指向 char 的常量指针

auto

类型说明符 auto 让编译器根据初始值来推断变量的类型,因此 auto 定义的变量必须初始化。一条 auto 声明语句中可以声明多个变量,这些变量的基数据类型必须一样。

auto i = 0, *pi = &i; // i 是整型,pi 是整型指针

有时,编译器会适当改变初始值类型来推断 auto 类型,使其更符合初始化规则。

  1. 使用引用初始化一个非引用对象时,会根据所引用对象类型推断 auto 类型。
  2. auto 推断一般会为所声明的变量忽略掉顶层 const,保留底层 const。顶层 const 需要明确指出。
  3. 可以在一条 auto 语句中定义多个变量,此时初始值必须是同一类型。
int i = 0, &ri = i;
auto a = ri; // int a

const int ci = i, &cr = ci;
auto b = ci; // int b
auto c = cr; // int c
auto d = &i; // int *d
auto e = &ci; // const int *e
const auto f = ci; // const int f

auto &g = ci; // const int &g
const auto &h = 42; // const int &h

auto k = ci, &l = i; // int k, int &l
auto &m = ci, *p = &ci; // const int &m, const int *p

decltype

类型说明符 decltype 可以选择并返回操作数的数据类型。编译器会分析表达式并得到它的类型,但不会实际计算表达式的值。

decltype(fn()) sum = x;

如果 decltype 接收的是一个变量,返回的是该变量自身的类型,而不是所引用/指向的对象类型。

const int ci = 0, &cj = ci;
decltype(ci) x = 0; // const int x
decltype(cj) y = x; // const int &y

如果接收的不是一个变量,则返回表达式结果对应的类型;有些表达式向 decltype 返回的是一个引用类型,一般当表达式结果能作为赋值语句的左值时会出现这种情况,比如 *p

int i = 42, *p = &i, &r = i;
decltype(r + 0) b; // int b
decltype(*p) c; // int &c

decltype((i)) d; // int &d
decltype(i) e; // int e

decltype 的结果类型与接收参数的形式有关。如果接收的是一个不加括号的变量,返回该变量的类型;如果接收的是一个加括号的变量,编译器将它当作一个表达式,而变量可以作为赋值语句左值,因此返回的是引用类型。

decltype((variable)) 的结果是引用,decltype(variable) 只有在 variable 是引用时才返回引用。

自定义数据结构

数据结构就是把一组数据组织起来然后使用它们的策略和方法。

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

struct A {} b, c;

struct 语句中的花括号构成了一个作用域,内部定义的名字必须唯一,但可以和外部的名字重复。struct 声明体后面可以紧跟变量名来定义该类型的对象,但最好将 struct 的定义与对象的定义分开。

类体定义类的成员,这里只有数据成员。数据成员定义了对象的具体内容,不同对象的数据成员彼此独立。数据成员的定义和普通变量一样:一个基数据类型后跟着一个声明符列表。数据成员可以有一个类内初始值(in-class initializer),创建对象时类内初始值用于初始化数据成员,没有初始值的成员将被默认初始化。类内初始值可以使用 {}=,但不能使用 ()

类一般不在函数体内定义。为了确保各个文件中类的定义一致,类通常定义在头文件中,类所在头文件的名字应和类的名字一样。头文件通常包含那些只能被定义一次的实体,比如:类、constconstexpr 变量。头文件也经常用到其它头文件的功能。同一个头文件可能被显式、隐式地包含多次,因此需要在书写头文件时做适当处理,使得多次包含也能安全、正常地工作。

头文件一经改变,相关的源文件必须重新编译以获取更新过的声明。

确保头文件多次包含仍能安全工作的常用技术是预处理器(preprocessor)。预处理器是编译前执行的一段程序,可以部分改变原程序。预处理器遇到 #include 指令,就会用指定的头文件内容代替。头文件保护符(header guard)也是一项预处理功能。头文件保护符依赖于预处理变量,预处理变量有两种状态:已定义和未定义。#define 指令将一个名字设定为预处理变量,还有两个指令用于检查指定的预处理变量是否已经定义,#ifdef 当且仅当变量已定义时为真,#ifndef 当且仅当变量未定义时为真,一旦检查结果为真,执行后续操作直到遇到 #endif 指令为止。使用这些功能可以有效地防止重复包含发生。

#ifndef SALES_DATA_H
#define SALES_DATA_H
#include <string>
struct Sales_data {
    std::string bookNo;
    unsigned int units_sold = 0;
    double revenue = 0.0;
};
#endif

预处理变量无视作用域规则。

头文件即使没有包含在其他头文件中,也应该设置保护符。

整个程序中的预处理变量包括头文件保护符必须唯一,通常基于头文件中类的名字来为保护符命名,以确保唯一性。预处理变量名通常大写。