读C++固件时,经常能看到这样的入口代码:
auto& app = Application::GetInstance();
app.Initialize();
app.Run();第一行取得应用对象的引用,后面两行通过这个引用调用成员函数。要判断这里有没有复制对象,需要查函数的返回类型,也要看变量声明里的&。
对写惯C的人来说,指针已经很熟悉。C++又加入了引用和类型推导,几个符号连在一起,读代码时容易混淆。下面用一个普通电机对象逐项说明,示例按C++17检查。
同一台电机,三种访问方式
struct Motor { int speed = 0; };
Motor motor;
auto copy = motor;
auto& ref = motor;
auto* ptr = &motor;
copy.speed = 20;
ref.speed = 30;
ptr->speed = 40;第一个变量copy是独立的Motor对象,由原对象复制初始化。改变它的速度,不会改变原来的电机数据。
ref的类型是Motor&,它是原对象的另一个名字。ptr的类型是Motor*,保存原对象的地址;后面两次写入都修改同一份数据。
所以执行完这段代码,副本的速度为20,原对象的速度为40。引用用.访问成员,指针常用->。图中的箭头表示访问关系,不规定编译器必须怎样安排引用的存储。

auto根据右边推导类型
Motor& GetMotor(); // 假设返回已有对象的引用
auto value = GetMotor();
auto& alias = GetMotor();
auto p1 = &motor;
auto* p2 = &motor;函数返回Motor&,调用处的value仍然被推导成Motor。对这个普通可复制类,它会复制函数返回的那个对象;alias才保留引用形式。
后两行得到的都是指针。普通auto也能推导出指针类型;写成auto*,则表明初始化结果需要符合指针声明的要求。
类型推导发生在编译期,不会让变量变成运行期动态类型,也不会仅因使用auto而分配堆内存。普通值初始化还可能涉及移动或直接构造,不能把所有auto都算成复制。
引用赋值,不会更换对象
Motor left{10}, right{80};
auto& current = left;
current = right;
Motor* selected = &left;
selected = &right;current初始化时就绑定到左侧对象,之后不能重新绑定。这里的赋值会把右侧对象的成员值赋给左侧对象,current仍然引用左侧对象。
指针则可以改变保存的地址。最后一行执行后,selected指向右侧对象。查找可能失败、设备可为空,或程序需要切换目标时,指针能表达这些状态;解引用前仍要确认目标有效。
单例返回引用,接收时也要留意
static Application& GetInstance() {
static Application instance;
return instance;
}这是类中的一个静态成员函数示例。函数保存一个局部静态对象,并把它的引用返回给调用者。该对象不会随着一次函数返回被销毁。
再比较调用处的两种写法:
auto& app = Application::GetInstance();
// 上面访问已有实例
// 类允许复制时,下面尝试初始化独立对象:
// auto copy = Application::GetInstance();带引用的声明访问原实例。去掉引用后,会尝试创建独立对象;若类删除了复制构造函数,就会编译失败。单例设计还需要构造权限与复制限制等约束,仅有获取函数不能保证所有代码都只能创建一个实例。
设备类也有相似问题:复制一个保存硬件句柄的对象,可能只复制了句柄数值,底层硬件资源仍然共用。
const限制通过该引用修改
const Motor& source = motor;
auto a = source;
auto& b = source;
const auto& c = motor;这里a是值对象,可以单独修改;b推导为const Motor&,保留了原有的只读限制;c也是只读引用。
因此,auto&是否允许修改对象,还取决于初始化对象本来的类型。只读引用限制的是这条访问路径,其他代码仍可能修改原对象。它不提供线程同步保证。

引用的寿命,要单独检查
const auto& temp = Motor{};这个局部引用直接绑定临时对象,按相应语言规则,临时对象的寿命会延长到该引用的作用域结束。
对已经存在的对象建立引用,则不会替它取得所有权。原对象销毁后,引用可能悬空;如果函数错误地返回局部变量的引用,调用处再加const也无法修复。
需要长期保存的设备引用,必须确认设备对象由谁持有,以及设备释放后还有没有代码会访问它。
寄存器快照和持续访问
volatile std::uint32_t& reg = StatusReg();
auto snapshot = reg;
auto& live = reg;这里假设函数返回有效寄存器对象的引用。初始化snapshot时读取一次,得到普通的std::uint32_t值,后续使用这份快照。
live保留了volatile引用形式,通过它访问仍然具有相应语义。实际硬件中,读清零、访问宽度等副作用都要按芯片手册处理,不能随意增加读取次数。volatile也不保证原子性或任务间同步。
auto&&的结果也要看右边
auto&& x = motor; // 推导为 Motor&
auto&& y = Motor{}; // 推导为 Motor&&前一行绑定左值对象,后一行绑定临时对象。推导与引用折叠让两行得到不同结果,不能只凭声明里出现两个&就认定变量一定是右值引用。
另外,有名字的变量表达式y本身是左值。移动语义与完美转发还有其他规则,这里先记住这两个普通推导结果。
根据对象用途选择

保存独立数据时,先检查值初始化是否产生复制,以及副本是否仍与原对象共享底层资源。操作已有设备时,引用可以清楚地表达借用关系;允许为空或需要更换目标时,指针更便于表达。
符号还要连着位置读:
Motor& ref = motor; // 声明引用
Motor* ptr = &motor; // 声明指针,并取地址
(*ptr).speed = 60; // 解引用,再访问成员声明中的&与表达式里的取地址不同,声明中的*与表达式里的解引用也不同。上面最后一行与通过指针箭头访问成员效果相同。
对寄存器、通信字段和固定宽度数据,显式类型仍然很有用:
auto timeout = 1000; // int
std::uint32_t timeout_ms = 1000;第一行不会根据用途自动变成32位无符号整数,第二行明确规定了类型。
读到一条设备对象的声明,先确认它取得的是副本、引用还是地址,再查原对象的保存位置和有效期。代码要操作哪一份状态,应当能沿着这条访问关系找到。