Lecture 3 (Week 2 - Tuesday): 初始化与引用 (Initialization & References)
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;声明ref是obj的别名——两者绑定同一块内存,对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被隐式转回double(100.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_vec是vec的别名:对它push_back等于对vec操作(同一块内存、同一份数据);const_ref是 const 引用:它指向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;
}
代码做什么:greet 以 const 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:补全
Coursestruct:用列表初始化构造每个 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;遇到”函数改了但外面没变”的诡异现象,第一反应检查形参是不是漏了&。
