Lecture 3 (Week 2 - Tuesday): 初始化与引用 (Initialization & References)

目录 · ← l2 · l4 →

Lecture 3 (Week 2 - Tuesday): 初始化与引用 (Initialization & References)

概述

本讲是”写出正确、高效 C++”的关键一课,两个主题:初始化引用。初始化部分讲清 C++ 的四种基本初始化形式(默认、拷贝、直接、列表)以及列表初始化对窄化转换(narrowing conversion)的编译期拦截;引用部分讲清引用是”变量的别名”、绑定同一块内存、必须绑定左值且不可重绑定,并由此引出 const 引用值传递 vs 引用传递的选择。课堂代码(initialization.cpp、references.cpp、const.cpp、Reactor.cpp)全部围绕”初始化写错会怎样”“参数怎么传才高效且安全”展开——这正是 A1: SimpleEnroll 的核心考点。

核心特性与语法详解

  • 默认初始化(Default Initialization)
    • 定义与目的T x; 不提供任何初值。对类类型(如 std::string)调用默认构造函数;对内置类型(int、double…)不初始化——值是”不确定的”(indeterminate),读取它是未定义行为。
    • 核心语法int x; / std::string s;
    • 设计意图与最佳实践:内置类型的默认初始化不写内存(追求零开销),代价是需要程序员自律。黄金法则:内置类型永远用 {} 或显式初值初始化。
  • 拷贝初始化(Copy Initialization)
    • 定义与目的T x = value;,从已有值”拷贝”出新的对象。
    • 核心语法int x = 5; / std::string s = "hi";
    • 设计意图与最佳实践:C 语言风格的写法,直观但语义上走的是拷贝路径;对于类类型可能调用移动/拷贝构造函数(现代编译器通常会优化掉多余的拷贝,即 copy elision)。
  • 直接初始化(Direct Initialization)
    • 定义与目的T x(args);,用圆括号参数直接构造对象,允许窄化转换
    • 核心语法int x(5); / std::vector<int> v(10);(构造 10 个元素)
    • 设计意图与最佳实践:圆括号会触发”最令人头痛的解析”(most vexing parse)等历史包袱,且不做窄化检查——Reactor.cpp 的 bug 正源于此(见示例 3)。
  • 列表 / 统一初始化(List / Uniform Initialization,C++11 起)
    • 定义与目的T x{ ... }; 用花括号初始化,对所有类型语法统一,且在编译期拒绝窄化转换
    • 核心语法int x{5}; / StanfordID id{ "Tree", "theTREE", 2 }; / T x{};(值初始化,内置类型清零)
    • 设计意图与最佳实践:C++11 引入它正是为了”统一 + 安全”。现代 C++ 首选 {}T x{} 会把内置类型零初始化,杜绝”未初始化变量”类 bug。
  • 窄化转换(Narrowing Conversion)
    • 定义与目的:隐式转换中丢失信息的一类:如 double→int(截断小数)、long→int(可能溢出)、double→float(精度可能丢失)。
    • 核心语法int numOne{12.0}; → ❌ 编译错误 narrowing conversion of '1.2e+1' from 'double' to 'int'
    • 设计意图与最佳实践:列表初始化把窄化从”静默的错误”变成”编译错误”;若确实需要截断,用显式类型转换(如 static_cast<int>(12.8)),让意图可见。注意例外:float f{12.0};合法的——因为 12.0 能被 float 精确表示,不构成窄化(标准允许”常量表达式且目标类型可精确表示”的情况)。
  • 引用(References,C++98 已有)
    • 定义与目的T& ref = obj; 声明 refobj别名——两者绑定同一块内存,对 ref 的读写就是对 obj 的读写。
    • 核心语法
      int main() {
          int x = 5;
          int& r = x;   // r 是 x 的别名(与 x 绑定同一块内存)
          r = 10;       // 通过 r 改写 x:x 也变成 10
          return 0;
      }
      
    • 设计意图与最佳实践:三条铁律:① 引用声明时必须绑定一个对象(不能”先声明后绑定”);② 不可重新绑定r = y; 是把 y 的值赋给 x,不是让 r 改指 y);③ 非 const 左值引用只能绑定左值(可寻址的具名对象),不能绑定字面量/临时值(如 int& r = 5; ❌)。
  • const 与 const 引用(const 正确性)
    • 定义与目的const T 表示”只读对象”;const T& 表示”只读视图”——既享受引用”不拷贝、直接看原对象”的高效,又保证不修改
    • 核心语法const int x = 5; / const std::vector<int>& cr = vec; / void print(const std::string& s);
    • 设计意图与最佳实践:const 引用可以绑定任何东西:左值、右值、字面量(const int& r = 42; 合法,临时对象生命周期被延长)。对 const 对象只能调用 const 成员函数(如 vec.size()),调用非 const 成员(如 push_back)是编译错误——const 是编译器替你执行的纪律
  • 值传递 vs 引用传递
    • 定义与目的:函数形参决定”数据怎么进来”:按值(拷贝一份,函数内修改不影响调用方)vs 按引用(直接操作原对象,可修改调用方)。
    • 核心语法
      void byValue(int n);          // 拷贝
      void byRef(int& n);           // 可修改原值
      void byConstRef(const int& n); // 只读,不拷贝
      
    • 设计意图与最佳实践:选型口诀——需要修改实参 → 传 T&;只读且类型较大(string、vector、struct)→ 传 const T&(避免昂贵拷贝);内置小类型(int、double)只读 → 直接按值传。这就是”不浪费时间和空间”哲学在参数传递上的体现。

代码示例与逐步解说(核心)

示例 1:四种初始化形式总览(C++11)

#include <iostream>
#include <string>

struct Point {
    double x;
    double y;
};

int main() {
    // 默认初始化:内置类型未初始化(值不确定,别读它!)
    int a;                       // 不确定值
    std::string s;               // 空字符串 ""(类类型有默认构造)

    // 拷贝初始化
    int b = 42;
    std::string t = "hi";

    // 直接初始化(圆括号,允许窄化)
    int c(3.9);                  // c == 3(小数被截断,不报错!)

    // 列表初始化(统一初始化,拒绝窄化)
    int d{7};                    // OK
    Point p{1.0, 2.0};           // 按字段顺序初始化
    // int e{3.9};               // ❌ 取消注释即编译错误:narrowing conversion

    // 值初始化:内置类型清零
    int f{};                     // f == 0
    std::cout << b << " " << c << " " << d << " " << f << std::endl;
    return 0;
}

代码做什么:一行行声明不同初始化方式的变量;c 用圆括号从 3.9 截断得到 3(静默),e 用花括号直接编译失败。

特性机制解说

  • 编译器对 {} 逐项做窄化检查double → int 属于”浮点转整型”,必然可能丢精度,属于窄化,直接报错;圆括号初始化走旧的隐式转换规则,3.9 静默截断为 3——同一个”错误”,两种初始化写法给出截然不同的命运;
  • Point p{1.0, 2.0} 走聚合初始化:按成员声明顺序填入;
  • int f{}值初始化,等价于 int f = 0;,规避了默认初始化”不确定值”的坑。

示例 2:课堂代码 initialization.cpp(C++11,验证窄化保护)

#include <iostream>

int main() {
    // 列表初始化:窄化转换被编译器拦截
    // int numOne{12.0}; // ❌ 取消注释即编译错误:double → int 是窄化
    float numTwo{12.0};  // ✅ 合法:12.0 可被 float 精确表示,不构成窄化

    std::cout << "numTwo is: " << numTwo << std::endl;
    return 0;
}

代码做什么:这是课堂真实代码(cs106l_data/lecture_code/lecture03/initialization.cpp)的改编版:原代码中 int numOne{12.0}; 编译失败,报错 error: narrowing conversion of '1.2e+1' from 'double' to 'int'(这里已注释掉并保留 float numTwo{12.0}; 展示合法情形)。

特性机制解说

  • 窄化判定的标准规则:浮点 → 整型永远是窄化(无例外),所以 int{12.0} 必错;
  • double → float 是否窄化取决于”源是常量表达式且值在目标类型可表示范围(哪怕不精确)”——12.0 恰好能被 float 精确表示,故合法;若写成 float f{12.1};(12.1 无法精确表示)就会报错。这正是”编译器用规则替你把关”的体现,也是本讲反复强调”用 {}“的原因。

示例 3:课堂代码 Reactor.cpp——窄化转换的真实代价(C++11)

#include <iostream>

class Reactor {
public:
    Reactor(double temperature) : temperature(temperature) {}   // 构造函数,后续讲座详讲
    void checkCool() {
        if (temperature > 100.0) {
            std::cout << "Emergency cooling!" << std::endl;
        } else {
            std::cout << "Temperature is normal. No emergency cooling required" << std::endl;
        }
    }
private:
    double temperature;
};

int main() {
    // 直接初始化(圆括号):允许窄化,100.8 被静默截断为 100
    int criticalTemperature(100.8);
    Reactor reactor(criticalTemperature);   // int 100 → double 100.0
    reactor.checkCool();                     // 输出 "Temperature is normal."!
    return 0;
}

代码做什么(课堂真实代码,cs106l_data/lecture_code/lecture03/Reactor.cpp):想模拟”温度超过 100°C 触发紧急冷却”,实际运行时输出却是 “Temperature is normal. No emergency cooling required”

特性机制解说

  • int criticalTemperature(100.8);直接初始化,圆括号允许窄化:100.8 静默截断为 100
  • 随后 100 被隐式转回 double100.0),100.0 > 100.0 为假 → 冷却逻辑从未触发
  • 这是一个”看似无害实则致命”的静默 bug——工业 PLC / 反应堆控制系统正是这类代码的高发区。若把第 32 行改成 int criticalTemperature{100.8};,编译器会当场报错,从根源上避免灾难。这就是列表初始化存在的意义:把”静默的错误”变成”编译期的错误”。

示例 4:课堂代码 references.cpp——值传递 vs 引用传递(C++11)

#include <iostream>

// 引用版本:直接修改调用方的变量
void squareN(int& n) {
    n = n * n;   // 直接改写实参(课堂原版用 std::pow(n, 2),效果相同)
}

int main() {
    int num = 5;
    std::cout << "(1) num is: " << num << std::endl;   // 5
    squareN(num);                                       // 按引用传:修改的就是 num 本身
    std::cout << "(2) num is: " << num << std::endl;   // 25
    return 0;
}

代码做什么(改编自课堂代码 references.cpp):squareN(int& n) 把参数按引用传入,函数内 n = n * n; 直接改写了 main 中的 num,输出 5 → 25。

特性机制解说

  • 原课堂代码用 #if WITH_REF 宏在”int& n(引用)”与”int n(值)”两个版本间切换:值版本下 main 里的 num 保持不变(拷贝被平方),引用版本下 num 被真正改写;
  • 原代码调用 squareN(5);(字面量)在引用版本下无法编译——报错 cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int':非 const 左值引用只能绑定左值(具名、可寻址的对象),5 是右值。这是引用最经典的规则,也是新手最常见的报错之一;
  • 通过引用修改实参的能力,正是 A1 中 parse_csv 需要”把填好的 vector 交还给调用方”的机制。

示例 5:课堂代码 const.cpp——const 对象与 const 引用(C++11)

#include <iostream>
#include <vector>

int main() {
    std::vector<int> vec{ 1, 2, 3 };                  // 普通 vector
    const std::vector<int> const_vec{ 1, 2, 3 };      // const 对象
    std::vector<int>& ref_vec{ vec };                 // 普通引用:vec 的别名
    const std::vector<int>& const_ref{ vec };         // const 引用:只读视图

    vec.push_back(3);          // ✅ OK:vec 可变
    // const_vec.push_back(3); // ❌ 编译错误:const 对象不能调用非 const 成员函数
    ref_vec.push_back(3);      // ✅ OK:ref_vec 是 vec 的别名,vec 实际变成 {1,2,3,3}
    // const_ref.push_back(3); // ❌ 编译错误:const 引用禁止修改
    return 0;
}

代码做什么(课堂真实代码 const.cpp):声明四种变量后尝试 push_back,其中两行被编译器拒绝。

特性机制解说

  • const_vec 是 const 对象:编译器禁止任何修改操作,push_back(非 const 成员函数)报错 passing 'const std::vector<int>' as 'this' argument discards qualifiers
  • ref_vecvec别名:对它 push_back 等于对 vec 操作(同一块内存、同一份数据);
  • const_refconst 引用:它指向 vec(不拷贝),但只读——既高效(无拷贝)又安全(不可改);
  • 本质区别const_vec 拷贝了一份 const 数据;const_ref 没有拷贝,只是”隔着一层只读玻璃看 vec“。当 vec 后续变化时,const_ref 看到的是变化后的新值——引用是别名,不是快照。

示例 6:高效传参——const std::string&(C++11)

#include <iostream>
#include <string>

// 只读大对象:const 引用,不拷贝、不修改
void greet(const std::string& name) {
    std::cout << "Hello, " << name << "!" << std::endl;
}

int main() {
    std::string me = "CS106L";
    greet(me);            // 绑定左值:OK
    greet("Stanford");    // 绑定字面量(右值):OK,const 引用可以绑定右值
    return 0;
}

代码做什么greetconst std::string& 接收参数,两次调用都成功。

特性机制解说

  • 若形参是 std::string name(按值),每次调用都要深拷贝整个字符串(分配堆内存 + 复制字符);const std::string& 零拷贝、零修改;
  • const 引用是”万能绑定”:左值、右值、字面量通吃(const int& r = 42; 合法,临时对象生命周期被延长到引用消亡);
  • 对比:非 const 引用 std::string& 只能绑定左值,且意味着”函数可能修改实参”——所以只读参数一律写 const T&,把意图交给编译器监督。

与旧标准(如C++98)的对比

  • C++98 没有列表初始化:只有 T x;T x = v;T x(v); 三种,窄化转换全部静默发生——Reactor.cpp 那种 int x(100.8) 截断 bug 在 C++98 里是合法且无声的,只能靠程序员肉眼发现。
  • C++11 的 {} 统一了初始化语法:聚合、类、内置类型一视同仁,并内建窄化检查;C++98 的聚合初始化 T x = { ... }; 仅适用于无构造函数、无私有成员的”聚合”,且不检查窄化。
  • 引用本身 C++98 就有(引用是 C++ 早期特性),但 C++98 时代惯例是”裸指针传参 + const 指针”,引用被广泛接受为现代风格是 C++11 之后的事;const T& 传参的”零拷贝 + 只读”理念在 C++98 同样成立,只是表达与工具(如 range-for)远不如现代版顺手。
  • 值初始化 T{}nullptr、range-for 等:均为 C++11 起的新特性,C++98 中”清零内置变量”只能手写 int x = 0;

关键要点

  • 现代 C++ 优先用列表初始化 {}:语法统一,且编译期拦截窄化转换;内置类型用 T x{} 保证清零,杜绝未初始化读取。
  • 窄化转换(double→int、long→int 等)会静默丢信息:圆括号/赋值允许它,花括号拒绝它;确需截断时用 static_cast<int>(...) 显式表达。
  • 引用是变量的别名,绑定同一块内存:声明时必须绑定、不可重绑定、非 const 引用只能绑定左值;通过引用修改,实参同步变化。
  • const T& 是”只读高效视图”:零拷贝、禁止修改,可绑定左值/右值/字面量——只读参数的标准答案。
  • 参数选型口诀:要修改实参 → T&;只读大对象 → const T&;只读小内置类型 → 按值。

常见陷阱与注意事项

  • 读取未初始化的内置变量int x; std::cout << x; 是未定义行为(值不确定);用 int x{}; 或给初值。
  • 窄化静默截断int x = 3.9; / int x(3.9); 编译通过但 x == 3;想保留小数就别用 int,想截断就用显式转换。
  • 非 const 引用绑定右值int& r = 5;void f(int&); f(5); 编译错误;要么传具名左值,要么改成 const int&
  • 悬垂引用(dangling reference):引用指向的对象先消亡(如函数返回局部变量的引用)后继续使用——未定义行为。int& bad() { int x = 1; return x; } ❌。
  • 误以为引用可重绑定int& r = a; r = b; 是把 b 的值赋给 a,不是让 r 改指 b;想”换个对象”应使用指针。
  • 忘记 & 导致悄悄拷贝:形参写成 T name 而非 const T& 时,大对象(string/vector/struct)每次调用都深拷贝,性能退化且行为上”改了也白改”。
  • 对 const 对象调用非 const 成员函数const_vec.push_back(...) 编译错误;只读访问用 size()operator[] 读取等 const 成员。

关联作业提示

A1: SimpleEnroll(第 2 周周五发布、一周后截止)——本讲的初始化与引用正是 A1 的核心考点:

  • Part 0:补全 Course struct:用列表初始化构造每个 Course 记录(Course c{ title, units, quarter };),字段顺序与 struct 声明一致;字符串字段用 {} 初始化最稳妥。
  • Part 1:parse_csv:函数需要”把解析出的 Course 填进调用方的 vector”——因此形参必须是引用void parse_csv(std::vector<Course>& courses, ...)。若漏写 &,函数内 push_back 的只是副本,调用方看到的 vector 仍为空——这是 A1 最经典的失分点。
  • Part 2/3:write_courses_offered / write_courses_not_offered:只读遍历(统计、比较 Quarter 是否为 “null”)用 const 引用传参与遍历(如 const std::string&),避免字符串深拷贝;需要从 all_courses 删除已开课程时,删除操作必须发生在遍历之后(边遍历边删会失效,见 A1 提示),删除本身依赖”vector 按引用传入”才能影响调用方。
  • 通用技巧:先本地编译 g++ -std=c++20 main.cpp -o main 验证,再跑内置 autograder;遇到”函数改了但外面没变”的诡异现象,第一反应检查形参是不是漏了 &