2. 变量和基本类型

2.1 变量

2.1.1 对象的定义

具有某种数据类型的内存空间。

2.1.2 变量的初始化

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

  • 变量的初始化和赋值是不同的:
    • 初始化的含义: 创建变量时赋予其一个初始值
    • 赋值的含义: 把对象当前的值擦除,而以一个新值来替代
  • 定义于任何函数体之外的变量被初始化为 0
  • 定义在函数体内部的内置类型变量将不被初始化。一个未被初始化的内置类型变量的值是未定义的,如果试图拷贝或以其他形式访问此类值,将引发错误。
1
2
3
4
5
6
int main() {
int a; // 由于此处未给a进行初始化
printf("a=%d", a); // 对a进行了访问,在VS中编译将会报错,而在linux中则可以正常运行,但是数值每次运行程序时,并不固定
printf("b=%d", b);
return 0;
}
  • 每个类各自决定其初始化对象的方式,而且,是否允许不经初始化就定义对象也由类自己决定。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <stdlib.h> // 在C语言使用malloc需要引用stdlib.h,如果使用iostream则无需另外添加stdlib.h

struct Another {
int a;
};

int b;

int main() {
struct Another* struc = (struct Another*)malloc(sizeof(struct Another));
int a = 5;
printf("a=%d", struc->a);
printf("b=%d", b);
printf("d=%d"); // 警告 C6064 缺少"printf"的整型参数(对应于转换说明符"1")。会打印出来不被定义的值 -1282888768(该值多次运行并不一定),且多次打印 format '%d' expects a matching 'int' argument
printf("d=%d"); // 警告 C6064 缺少"printf"的整型参数(对应于转换说明符"1")。会打印出来不被定义的值 -1282888768,和上方一样
return 0;
}

2.1.3 列表初始化

作为 C++11 新标准的一部分,用花括号来初始化变量得到了全面应用,而在此之前,这种初始化的性质仅在某些受限的场合下才能使用。

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

int main() {
// int units_sold = 0;
// int units_sold = {0};
// int units_sold{0};
int units_sold(0);
std::cout << units_sold << std::endl;
}

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

1
2
3
4
5
6
7
8
9
10
11
12
int main() {
long double ld = 3.1415926535897932384626433832795028841971693993751058209749445923078164062862089986280348253421170679;
int a{ld}, b = {ld}; // 这里在VS里面会直接不过编译,报错,但是在linux只会warning // C++11 style initialization
int c(ld), d = ld; // C++98 style initialization
}

// **LINUX**
// warning: narrowing conversion of 'ld' from 'long double' to 'int' [-Wnarrowing] 可以通过编译
// 10 | int a{ld},b = {ld};

// **WINDOWS**
// 错误 C2397 从"long double"转换到"int"需要收缩转换 testC C:\Users\24854\source\repos\testC\testC\testC.cpp 7

2.1.4 变量声明和定义的关系

  • extern 用法示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// main.cpp
#include <stdio.h>
#include <stdlib.h> // 在C语言使用malloc需要引用stdlib.h,如果使用iostream则无需另外添加stdlib.h
// #include "new.cpp" // ERROR! 注意,这里不可以include "new.cpp" 变量只能被定义一次,但是可以多次被声明

extern int p; // 这里只是声明
extern int func(); // 这里只是声明

int main() {
printf("%d\n", p);
int d = func();
printf("%d\n", d);
return 0;
}
1
2
3
4
5
6
7
8
9
10
// new.cpp
#ifndef NEW_H
#define NEW_H

int p = 15; // 可以是变量
int func() { // 也可以是函数
return p;
}

#endif
  • 变量的声明和定义
1
2
3
4
// 相关知识介绍
extern int i; // 声明了i而非定义i
int j; // 声明并定义j
extern double pi = 3.1416; // 定义pi(没有声明)
  • C++ 是一种静态类型 (statically typed) 语言,其含义是在编译阶段检查类型,其中,检查类型的过程称为类型检查 (type checking)
  • 我们已经知道,对象的类型决定了对象所能参与的运算。在 C++ 语言中,编译器负责检查数据类型是否支持要执行的运算,如果试图执行类型不支持的运算,编译器将报错,并且不会生成可执行文件。
  • 程序越复杂,静态类型检查越有助于发现问题。然而,前提是编译器必须知道每一个实体对象的类型,这就要求我们在使用某个变量之前,必须声明其类型。

2.2 复合类型

复合类型 (compound type) 是指基于其他类型定义的类型,C++ 语言有几种复合类型,本章将介绍其中的几种:引用和指针。

与我们已经掌握的变量声明相比,定义复合类型的变量要复杂很多。

  • 普通变量的定义方式:
    • 一条简单的声明语句由一个数据类型和紧随其后的一个变量名列表组成。
    • 更通用的描述:一条声明语句由一个基本数据类型和紧随其的一个声明符列表组成。每个声明符命名了一个变量并指定该变量为与基本数据类型有关的某种类型。

目前为止,我们所接触的声明语句中,声明符其实就是变量名,此时变量的类型也就是声明的基本数据类型,其实还可能有更复杂的声明符,它基于基本数据类型得到更复杂的类型,并把它指定给变量。

2.2.1 左值引用(右值引用暂时不介绍)

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

1
2
3
int ival = 1024;
int &refVal = ival;
int &refVal2; // ERROR, 引用必须被初始化

一般在初始化变量时,初始值会被拷贝到新建的对象中。

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

⚠️ 注意: 引用的初始化流程有所区别

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

1
2
refVal = 2;      // 把2赋给refVal指向的对象,此处即是赋给了ival
int ii = refVal; // 与ii = ival 执行结果相同

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

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

引用只能绑定在对象上,而不能与字面值或某个表达式的计算结果绑定在一起,同时,在大部分情况下,所有的引用类型都要和与之绑定的对象严格匹配。

2.2.2 指针

指针 (pointer) 是指向另外一种类型的复合类型。

2.2.2.1 指针和引用的区别

  • 指针本身就是一个对象,允许对指针赋值和拷贝。而且指针的生命周期内它可以先后指向几个不同的对象
  • 指针在定义的时候无需赋初值。和其他内置类型一样,如果没初始化,也会有不确定的值(野指针)

2.2.2.2 获取对象的地址

1
2
int ival = 42;	
int *p = &ival; // p存放变量ival的地址

在大部分情况下,所有的指针类型也都要和与之绑定的对象严格匹配。

2.2.2.3 空指针

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

1
2
3
4
int *p = nullptr;   // 等价于 int *p1 = 0;
int *p2 = 0; // 直接将p2初始化为字面常量0
// 需要先 include cstdlib
int *p3 = NULL; // 等价于 int *p3 = 0;

⚠️ 重要提示: 当基类指针指向派生类的时候,若基类析构函数不声明为虚函数,在析构时,只会调用基类而不会调用派生类的析构函数,从而导致内存泄露。

2.3 const 限定符

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

值得注意的是:const 对象一旦创建之后就不可以再改变,所以 const 对象必须初始化。

2.3.1 const 变量的初始化特性

1
const int k;  // 错误,k是一个未经初始化的常量

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

1
2
3
int i = 42;
const int ci = i; // 正确:i的值被拷贝给了ci
int j = ci; // 正确:ci的值被拷贝给了j

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

2.3.2 一般情况下,const 变量不能跨文件

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

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

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

🔴 编译器将在编译的过程中,把所有用到 bufSize 的地方都替换成对应的值。

为了执行上述替换,编译器必须知道变量的初始值。如果程序包含多个文件,则每个用了 const 对象的文件都必须得能访问到它的初始值才行。要做到这一点,就必须在每一个用到变量的文件都有对它的定义

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

2.3.3 如何实现 const 变量的跨文件使用?

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

  • 如何只在一个文件中定义 const 变量,但在多个文件中使用它呢?

解决的办法是:对于 const 变量不管是声明还是定义都添加 extern 关键字,这样只需要定义一次就可以了。

``cpp
// file_1.cc 定义并初始化了一个常量,该常量能被其他文件访问
extern const int bufSize = fcn(); // 是定义

// file_1.h 头文件
extern const int bufSize; // 与file_1.cc 中定义的bufSize是同一个,是声明

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

如上述程序所示,`file_1.cc` 定义并初始化了 `bufSize`。因为这条语句包含了初始值,所以它是一次定义。然而,因为 `bufSize` 是一个常量,必须用 `extern` 加以限定使其被其他文件使用。

`file_1.h` 头文件中的声明也由 `extern` 做了限定,其作用是指明 `bufSize` 并非本文件所独有,它的定义将在别处出现。

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

### 2.3.4 Const 的引用

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

引用的类型必须与其所引用对象的类型一致。

```cpp
int ci = 1024;
const int &ri = ci; // 正确:引用及其对应的对象都是常量
ri = 42; // 错误:ri是对常量的引用
int &r2 = ci; // 错误:试图让一个非常量引用指向一个常量对象

“对 const 的引用”简称为”常量引用”,但严格来说,并不存在常量引用。因为引用不是一个对象,所以我们无法让引用本身恒定不变。事实上,由于 C++ 语言并不允许随意改变引用所绑定的对象,所以从这层意义上理解,所有的引用都算是常量。

1
2
3
double i = 5.14;
const int& ref = i; // 正确,实际上运行过程为 const int temp = i; const int & ref = temp;
int& p = i; // 错误,引用有严格的类型检查,绑定的对象一定和自己的类型一致

除此之外,const int&int const & 是完全等价的,两者都是对整数的常量引用,const 的位置不影响语义。

1
2
3
4
5
6
7
8
9
10
int i = 1024; 
const int& const_int_i = i; // 对const的引用可能引用一个并非const的对象
const double& const_double_i = i;
int& refi = i;

i = 5;
std::cout << const_int_i << std::endl; // 5,常量引用会正常的修改到
std::cout << const_double_i << std::endl; // 1024,修改了类型的常量流程为实际上运行过程为 const int temp = i; const int & ref = temp;
// 是额外开辟了一个变量,所以不会被修改到。
std::cout << refi << std::endl; // 5, refi 被修改到了
1
const int& r2 = 24;  // 正确,const int temp = 24; const int& r2 = temp;(实际上)

2.3.5 指针和 const

与引用一样,也可以令指针指向常量或非常量。类似于常量引用,指向常量的指针 (pointer to const) 不能改变其所指对象的值(不能改变指向的对象的值),即 *p 不可修改。要想存放常量对象的地址,只能使用指向常量的指针:

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

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

2.3.5.1 const 指针

指针是对象,因此可以把指针本身定为常量,常量指针 (const pointer) 必须初始化,初始化成功后,它的值(存放在指针中的地址)就不能再改变了。不能改变指向,即该变量 p 不可修改。

1
2
int errNumb = 0;
int * const curErr = &errNumb; // curErr将会一直指向errNumb

2.3.6 顶层 const

  • 顶层 const: 表示指针本身就是个常量,可以表示任意对象是常量,对任何数据类型都适用
  • 底层 const: 表示指针指向的对象是个常量,底层 const 则与指针和引用等复合类型的基本类型部分有关

当底层 const 进行拷贝操作时,拷入和拷出的对象都必须具有相同的底层 const 资格,或者两个对象的数据类型必须能够转换。一般来说,非常量可以转换为常量,反之则不行。

2.3.7 constexpr 和常量表达式

常量表达式 (const expression) 是指不会改变并且在编译过程就能得到计算结果的表达式。显然字面值属于常量表达式,用常量表达式初始化的 const 对象也是常量表达式。

一个对象是不是常量表达式由它的数据类型和初始值共同决定,例如:

1
2
3
4
const int max_files = 20;      // max_files 是常量表达式
const int limit = max_files + 1; // limit是常量表达式
int staff_size = 27; // staff_size 不是常量表达式
const int sz = get_size(); // sz 不是常量表达式

尽管 staff_size 的初始值是个常量,但由于 staff_size 的值可以改动,所以他并不是一个常量表达式。

同样的,sz 本身虽然是一个常量,但是他的值必须在运行时才能知晓,不可以在编译时就定下来,所以他也不是常量表达式。

2.3.7.1 constexpr 变量(constexpr 类型)

在一个复杂系统中,分辨一个初始值到底是不是常量表达式是很难的。

C++11 新标准规定,允许将变量声明为 constexpr 类型,以便编译器来验证变量的值是否是一个常量表达式,声明 constexpr 的变量一定是一个常量,并且必须要用常量表达式来初始化。

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

注意,不可以用普通函数作为 constexpr 变量的初始值。新标准允许定义一种特殊的 constexpr 函数,我们可以用 constexpr 函数去初始化 constexpr 变量。

2.3.7.2 字面值类型

声明 constexpr 变量时用到的类型必须有所限制,一般比较简单,值也显而易见,容易得到,就把它们称为 “字面值类型”

目前遇到的数据类型中:算术类型、引用、指针都属于字面值类型。类不属于字面值类型,也不能被定义为 constexpr

一个 constexpr 指针的初始值必须是 nullptr 或者 0,或是存储于某个固定地址的对象。

2.3.7.3 指针与 constexpr

如果 constexpr 声明中定义了一个指针,限定符 constexpr 仅对指针有效,与指针所指向的对象无关(无法切换指向)。

1
2
const int* p = nullptr;           // 指向常量的指针,底层const
constexpr int *q = nullptr; // 指向整数的常量指针,顶层const

与其他常量指针类似,constexpr 既可以指向常量,也可以指向变量。

1
2
3
4
5
6
7
constexpr int *np = nullptr;      // 指向整数的常量指针,顶层const
int j = 0;
constexpr int i = 42; // i的类型是整数常量

// i和j必须定义在函数体之外,全局变量(包括main函数)
constexpr const int* p = &i; // p是常量指针,指向常量i
constexpr int* p1 = &j; // p1是常量指针,指向变量j

2.4 处理类型

2.4.1 类型别名

类型别名 (type alias) 是一个名字,它是某种类型的同义词。

方法:

1
2
3
typedef double wages;       // wages是double的同义词
using wages = double; // wages是double的同义词
wages hourly, weekly; // 等价于 double hourly, weekly;

其中,typedef 作为声明语句中的基本数据类型的一部分出现,含有 typedef 的声明语句定义的不再是变量,而是类型别名

类型别名和类型的名字等价,只要是类型的名字能出现的地方,就能使用类型别名。

⚠️ 值得注意的是:

1
2
3
4
typedef int* pint;
int a = 5;
const pint b = &a; // 实际上是 int * const b = &a 指向int的常量指针
// 并不是直接的替换 const int* b : 指向int常量的指针

b 的基本数据类型是 const pint,const 是对给定类型的修饰。pstring 实际上是指向 int 的指针,因此 const pint 就是指向 int 的常量指针。

2.4.2 auto 类型说明符

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

1
2
auto i = 0, *p = &i;     // 正确;i是整数,p是整形指针 他们的基本数据类型都是int,*是修饰符
auto sz = 0, pi = 3.14; // 错误:sz 和 pi的类型不一致(int, double)

2.4.2.1 复合类型、常量和 auto

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

1
2
int i = 0, &r = i;
auto a = r; // a是一个整数 (r是i的别名,而i是一个整数)

auto 一般会忽略掉顶层 const,同时底层 const 则会保留(注重于对象,而不是指针本身)。

1
2
3
4
5
6
7
const int ci = i, &cr = ci;
auto b = ci; // b的类型为int,顶层const的特性被忽略掉了
auto c = cr; // c是一个整数,(cr是ci的别名,ci本身是一个顶层const,顶层const的特性被忽略掉了)
auto d = &i; // i本身是一个变量,d是一个整型的指针(整数的地址就是指向整数的指针)
auto e = &ci; // ci本身是一个顶层const,&i是一个底层const
// (对常量对象取地址是一种底层const)
// 底层const会保留,所以e的类型是const int* (指向整数常量的指针)

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

1
const auto f = ci;  // ci的推演类型为int,f是 const int

还可以将引用的类型设为 auto:

1
2
3
auto &g = ci;         // g 是一个整形常量的引用
auto &h = 42; // 错误,不能为常量绑定引用
const auto &j = 42; // 正确,可以为常量引用绑定字面值

2.4.3 decltype 类型指示符