Skip to content

1.1 重新认识变量

我们已经会声明变量:

cpp
int score = 75;

不过变量是一个有名字的盒子」只能解释最简单的情况。现代 C++ 的许多设计都围绕三个问题展开:一个对象何时存在、谁可以使用它、它何时被销毁。

变量、类型与对象

从变量到对象

可以先把对象(object)理解为一块具有类型和生命周期的存储。变量是通过名字指代对象的一种方式,但对象不一定有名字。例如调用函数时,可以直接构造一个没有名字的临时对象:print_name(std::string{"Alice"});

类型(type)决定了许多事情:

  • 对象占用多少空间以及怎样解释其中的数据;
  • 可以执行哪些操作;
  • 如何构造、复制、移动和销毁对象;
  • 编译器能帮助检查哪些错误。

但请注意:我们说它「具有」类型和生命周期,并不意味着每个对象旁边都会在运行时额外保存一份类型标签和销毁时间。类型与生命周期首先是 C++ 语言对程序行为的规定;编译器依据这些规则检查程序,并生成相应的机器码。

举个例子,在一种假想的汇编表示中:

cpp
int value{0};
value += 1;

编译器可能决定,value 位于当前栈帧起始位置向下 4 字节,于是这段代码可能被编译成:

asm
mov dword ptr [rbp - 4], 0
add dword ptr [rbp - 4], 1

机器指令中直接规定了应该怎样操作哪些比特。处理器执行这段指令时,通常不需要再查询「这个地址原来声明成了什么类型因为对应的操作已经写进机器码。调试信息、运行时类型识别等功能仍可能另外保留类型相关信息。

类型也并非完全不进入运行时:为了实现虚函数、dynamic_casttypeid 等能力,编译器仍然会生成一些运行时结构。

这是 C++「零成本抽象」理念(Zero-Cost Abstraction)的一个体现,我们在后续还会碰到它。零成本抽象是 C++ 的核心设计理念,指的是:

  • 不使用的功能,不应该为它付出成本。
  • 使用某种抽象时,其成本应当等同或接近人工实现同等功能的成本。

这意味着程序员在写 C++ 这一高级语言时,可以享受与手写底层代码几乎相同的性能。例如在这里,开发者并不会因为「变量有不同类型,对应不同操作」这件事付出额外的内存开销;只有真的用到了动态类型能力,才为这部分付出代价。

初始化

下面两段代码最后都得到 75,但发生的过程不同。

cpp
int first = 75; // 创建 first 时初始化

int second = 0; // 先初始化
second = 75;    // 再赋值给已经存在的对象

对于 int,这一区别似乎不大。以后遇到管理文件、内存等资源的类型时,初始化和赋值可能执行完全不同的工作。我们将在后续讨论这一点。

除了 int count = 10; 这种初始化语法之外,C++11 引入了使用花括号初始化的语法:

cpp
int count{10};
double average{82.5};

花括号初始化与原本 = 初始化的最大区别是:花括号会拒绝一部分可能丢失信息的隐式转换。标准明确规定,列表初始化中需要进行窄化转换时,程序不合法。例如这里编译器会拒绝从浮点数转换为整数:

cpp
int truncated = 3.14; // 编译通过,值为 3

int rejected{3.14};
// narrowing conversion of '3.1400000000000001e+0' from 'double' to 'int'

不仅是字面量,这样的限制对各种对象都生效。例如这里编译器会拒绝从浮点型变量转换为整型变量:

cpp
double distance = 12.8;

int truncated = distance; // 编译通过,值为 12
int rejected{distance};
// narrowing conversion of 'distance' from 'double' to 'int'

反过来,整数转换为浮点数通常也属于窄化转换。只有源是常量表达式,并且目标浮点类型能够准确表示该值、再转换回原类型仍得到原值时,列表初始化才允许这种转换。例如字面量 10 满足这个例外:

cpp
int runtime_value{10};
double rejected{runtime_value};
// non-constant-expression cannot be narrowed from type 'int' to 'double'

double value{10}; // 编译通过

按照 C++ 标准,这个 rejected 声明不合法,不应只报警告,应该直接报错。不过编译器在给出诊断后仍可以把它作为扩展继续编译,例如 GCC 配合 -Wpedantic 时通常只报告警告;使用 -pedantic-errors 可以让 GCC 拒绝这类写法。

特别地,花括号还可以用于初始化空值。我们知道,基础类型的局部变量如果没有初始化,其值通常是不确定的。读取这样的值可能导致未定义行为。花括号可以处理这一点。直接使用一堆空花括号初始化变量:

cpp
int count{};      // 0
double average{}; // 0.0
bool finished{};  // false

养成在声明时给出有效初值的习惯。

auto 让编译器推导类型

首先必须明确:auto 不是动态类型auto 关键字用于让编译器自动推导出类型,这个推导过程发生在编译时,适合十分明确或不便书写的类型。

cpp
auto score{75};        // int
auto average{82.5};    // double
const auto limit{100}; // const int

C++14 起,auto 也可以用于定义函数返回类型。例如:

cpp
auto foo() { return 42; }

此时返回值会自动推导为 int。但再次注意,这是自动推断的静态类型,而非动态类型!

cpp
auto foo(bool flag) {
    if (flag)
        return 42;
    else
        return false;
}
// inconsistent deduction for auto return type: 'int' and then 'bool'

auto 返回类型需要所有分支的 return 语句返回的表达式推导出相同类型。此外,如果函数体比较复杂,更推荐显式指定返回类型,也增强可读性。

C++20 起,auto 还能用于定义函数参数类型,使其拥有泛型或者说函数模板的性质。我们会在后续章节中详细介绍,现在留个印象即可。例如:

cpp
auto add(auto x, auto y) { return x + y; }

std::cout << add(1, 2) << '\n';
// 3

std::cout << add(12.5, 42) << '\n';
// 54.5

std::cout << add(std::string{"Hello"}, std::string{" world"}) << '\n';
// Hello world

std::cout << add("test", 2) << '\n';
// st
// 原因:字面量 "test" 返回指针,+2 到 s 的位置,读取直到 \0

只要可以用 + 算符,都可以传入。

字面量的类型

字面量也是具有类型的表达式。

对于整数字面量,例如 42,编译器会自动选择一个能够表示该值的整型,默认为 int。例如对于超出 int 的字面量,编译器可能会自动选择 longlong long

我们可以使用 typeid(<expression>).name() 来获取一个表达式的类型名称。需要注意的是,在不同平台、不同编译器上,名称可能会有所不同。

cpp
std::cout << typeid(42).name() << '\n';
// GCC 输出 i,MSVC 输出 int

std::cout << typeid(2147483648).name() << '\n';
// GCC 输出 x,MSVC 输出 __int64

但有时候我们想要指定整数字面量的类型。假设我们需要定义一个常量,其值为 ,考虑下面的代码:

cpp
const auto value{1 << 63};
// left shift count >= width of type

std::cout << value; // 0

这是为什么?因为这里的 1 默认为 int,左移 63 位超出了 int 的宽度,这是一个 UB,所以连输出的这个 0 都不能保证。

而编译器并不会因这样的运算而魔法般地帮你把这个 1 换成 unsigned long long。为此 C++ 为整数字面量提供了字母后缀语法,可以手动指定整数字面量的类型:

后缀含义
uunsigned
llong
lllong long

1 加上后缀 ull 之后警告消失,变成了正确的 unsigned long long 型。

cpp
const auto value{1ull << 63};
std::cout << value; // 9223372036854775808

而对于浮点数,一切浮点数字面量默认都是 double。加后缀 f 可以变成 float

cpp
std::cout << typeid(1.0).name() << '\n';
// GCC 输出 d,MSVC 输出 double

std::cout << typeid(1.0f).name() << '\n';
// GCC 输出 f,MSVC 输出 float

truefalse 的类型是 bool

停止使用 C 风格类型转换

Cast 有重铸金属的含义,英语中形象地用 type casting 描述强制类型转换。我们已经知道两种 C 风格的类型转换语法:(type)valuetype(value)。C++ 提供了四种专用的类型转换运算符,比 C 风格的转换更安全、更明确。我们这里先介绍其中的 static_cast

静态转换 static_cast 用于类型相近、语义明确的转换,例如基本数值类型的转换等等。编译器会在编译时进行一些检查。平时所写的大部分 C 风格的转换都可以使用 static_cast 替换。

cpp
const auto value{12.5};
std::cout << static_cast<int>(value); // 12

关于只读

const 表达只读意图

const 表示不能通过当前名字或接口修改对象。

cpp
const int passing_score{60};
passing_score = 50;
// assignment of read-only variable 'passing_score'

引用参数中的 const 很常见:

cpp
#include <string>

void print_name(const std::string &name);

它表示函数借用已有字符串,只读取而不修改。这里暂时不用深究引用传参的性能规则,只需观察到:类型签名已经向调用者表达了意图。

这并不意味着 const 指向的值永远不变!考虑如下代码:

cpp
int can_edit{1};
const int &no_edit = can_edit;

std::cout << no_edit << '\n'; // 1
can_edit = 2;
std::cout << no_edit << '\n'; // 2

no_edit = 3;
// assignment of read-only reference 'no_edit'

在这里,const 只限制以 no_edit 这个名字修改值的行为,但不会影响以 can_edit 这个名字修改值的行为。const 只表达它声明的这个名字只读意图

constexpr 表示编译期可确定

constexpr 则进一步表示某个对象或函数可以参与编译期求值。尽管具名的 constexpr 对象和字面量并不完全相同,但在满足常量表达式规则的上下文中,它的值可以在编译期使用,例如作为数组长度或交给 static_assert 检查。

cpp
constexpr int count{10};

constexpr int square(int value) { return value * value; }

int array[square(count)]; // 编译通过

static_assert(square(5) == 25); // 编译通过

constexpr 函数也是函数,传入运行时参数的时候可以在运行时执行。

static_assert 声明是 C++11 引入的,用于进行编译时断言检查,它接受布尔常量表达式并检查其为 true。如果不是常量表达式或值不是 true 都会报错。

并非所有 const 对象都是编译期常量,但 constexpr 对象一定是 const 的。

所以:

cpp
// 停止使用:
#define MAX_COUNT 100
#define PI 3.14

// 请使用:
constexpr auto max_count{100};
constexpr auto pi{3.14};

作用域和生命周期

作用域与生命周期的概念

作用域(scope)限制「在哪里可以访问一个名字,哪里不可以C++ 采用词法作用域(lexical scope作用域的继承由代码字面上的包含关系决定,外层无法访问内层的名字、内层可以访问外层的名字。

cpp
int main() {
    int outer{1};

    {
        int inner{2};
        outer += inner;
    } // inner 的作用域在这里结束

    // inner 在这里不可见,outer 仍然存在

} // outer 的作用域在这里结束

对象的生命周期(lifetime)是它作为某个类型的对象存在并可以被合法使用的时间。生命周期通常在取得合适存储并完成初始化后开始,在对象被销毁或存储被释放、复用时结束。需要注意,对于在生命周期内,并不意味着它就一定可以在某个地方被访问(在作用域内)。换句话说,作用域和生命周期虽然经常相关,但二者并不是同一个概念。

静态、自动与动态存储期

cpp
void example() {
    int local_var{42};              // 自动存储期
    static int static_var{42};      // 静态存储期
    int *dynamic_var = new int{42}; // 动态存储期
    delete dynamic_var;
}

这里的 local_var 是我们最经常使用的局部变量,通常放在函数的栈帧中,具有自动存储期会在离开函数时自动结束生命周期

这里的 static_var 使用 static 声明,在首次经过其声明时初始化,属于静态存储期。其生命周期不会在离开函数时自动结束,而且在所有后续调用中,初始化将被跳过。静态局部变量通常会在程序正常结束时自动销毁。

new 创建的对象不会因为指针变量离开作用域而自动销毁,忘记 delete 就会泄漏;提前或重复 delete 则会产生更危险的问题。

现代 C++ 并没有禁止动态内存,而是很少要求业务代码直接配对书写 newdelete。通常应由我们后续将会介绍的 std::vectorstd::string 或智能指针等类型管理它。我们先使用这些类型,后续再解释其所有权模型。

引用和指针不会延长生命周期

引用和指针可以指向别的对象,但它们通常并不拥有那个对象。

cpp
int *broken_pointer() {
    int local{42};
    return &local; // 返回后 local 已经不存在
}

const int &also_broken() {
    int local{42};
    return local; // 引用同样会悬空
}

返回的地址仍可能是一个数字,但它指向的对象已经结束生命周期,这样的指针称为悬空指针。解引用悬空指针会产生未定义行为。

「野指针」是口语化且边界不太统一的说法,未初始化指针和悬空指针都可能被这样称呼。避免使用这类说法。

IMPORTANT

指针或引用「看起来还能用」不代表其指向的对象仍然存在。判断代码是否安全时,要沿着生命周期思考,而不能只看地址是否为空。

cpp
const int *choose(bool use_inner) {
    static const int stable{10};
    if (use_inner) {
        int temporary{20};
        return &temporary;
    }
    return &stable;
}

阅读代码,不运行程序,指出哪些对象已经结束生命周期。

【解答】temporary 在离开 if 代码块时结束生命周期,因此第一条返回路径产生悬空指针;stable 具有静态存储期,在程序结束前一直存在。

再将其复制到 IDE 中或从命令行编译,观察警告。

小结

  • 对象是一块具有类型、生命周期的存储
  • 零成本抽象是 C++ 的核心设计理念
  • 使用花括号 {} 进行初始化
  • 使用 auto 让编译器推导类型(但仍然静态)
  • 使用 static_cast 进行类型转换
  • 使用 const 表达只读、constexpr 表达编译期确定
  • 关注作用域与生命周期,可访问不表示对象还存在,当心引用悬空