一个很小的 C++ 程序,也会不断地回答几个问题:这份数据是整数还是小数?它能不能被修改?转换后会不会丢失信息?这个名字在哪里可用?它指向的对象还活着吗?
这些问题都集中在“变量与类型”上。代码能通过编译只是起点;真正可靠的代码,会让数据的含义、范围和生命周期都尽量清楚。
先看一行平常的代码:
int tickets{3};它同时建立了几个概念:

int tickets{3}; 中,tickets 是定位运行期对象的名字,3 存储在按 int 规则解释的对象内部。类型并不只决定“占多少字节”。它还决定一组比特如何被解释,以及什么操作有意义。两个对象即使占用同样多的存储,只要类型不同,操作规则也可能完全不同。
变量名也不是值本身。下面的赋值修改了对象中的值,但它的名字和类型没有变:
tickets = 5;可以把一次读取理解为“取出对象当前的值”,把一次写入理解为“替换对象当前的值”。这个模型比“变量是一个盒子”更完整,因为它还保留了类型和生命周期。
引用稍有特殊:它可以有名字,但它是已有对象的别名,不是另一份独立的数据。后面会专门处理这个差异。
选类型时,先问数据表达什么,再问它的范围和精度。不要只看“现在这个值能不能放下”。

unsigned 还体现位模式与模算术的循环特征。void 表示“没有值”,常用于不返回值的函数;它不是可用来定义普通变量对象的类型。
short、int、long、long long 之间有最小范围和大小关系,但具体占多少字节取决于实现。同样,不要假定普通 char 在所有环境中都有相同的符号性。
可以用 sizeof 观察当前环境,用 std::numeric_limits 查询当前类型的能力:
#include <iostream>
#include <limits>
int main() {
std::cout << sizeof(int) << '\n';
std::cout << std::numeric_limits<int>::min() << '\n';
std::cout << std::numeric_limits<int
sizeof(char) 的结果按定义是 1,但 C++ 的一个字节不应在所有环境中被写死为某个比特数。如果代码依赖具体宽度,应该在接口边界明确验证。
42 // int
42u // unsigned int
3.14 // double
3.14f // float
'A' // char
true // bool
"A" // 包含 'A' 和 '\0' 的字符数组字面量'A' 和 "A" 不是同一种数据。前者是一个字符,后者包含结尾空字符,在许多表达式中会退化为指向首元素的指针。
需要精确计数时使用整数。例如金额可以保存为“分”,而不是默认认为 double 能精确保存每个十进制小数。
int price_cents{2599}; // 25.99 元,精确保存到分
double temperature{23.6}; // 测量值,允许有限精度近似unsigned 不会替你验证“数量不能为负”。如果从 0 再减 1,结果会按模回绕到很大的值。对业务约束,要明确检查输入和运算边界。
一个名字在使用前必须先被声明。声明告诉编译器“这个名字是什么”;定义还提供创建实体所需的完整信息。
extern int active_users; // 声明:定义在别处
int active_users{}; // 定义:创建 int 对象
double area(double); // 函数声明
double area(double r) { // 函数定义
return 3.14159 * r * r;
}每个定义都同时是声明,但并非每个声明都是定义。对变量而言,定义会创建对象;对函数而言,定义会给出函数体。
int* cursor, count;这里只有 cursor 是指针,count 是普通 int。把它们拆开更直观:
int* cursor{nullptr};
int count{};这个习惯还能让每个对象的初始值更显眼。
对便携的 ASCII 风格代码,名字可以由字母、数字和下划线组成,但不能以数字开头,也不能使用关键字。
int retry_count{}; // 清楚
double unit_price{}; // 清楚
bool is_member{}; // 清楚
int r{}; // 过短,除非作用域很小且语境明确
int RetryCount{}; // 合法,但应与项目风格一致不要自己创建以下划线开头的名字,也不要使用连续两个下划线。这些形式中有很多为实现保留。最简单的团队规则是:业务名字从字母开始,不写双下划线。
名字的长度应与作用域相匹配。三行循环中的 i 可以很清楚,全局范围的 x 则几乎无法表达意图。
编译器只能检查名字是否合法,无法保证它是否准确。days 很可读,但如果它实际保存小时,仍然会误导维护者。
初始化发生在对象生命周期开始时,它建立对象的第一个值。赋值面向一个已存在的对象,用新值替换旧值。
int attempts{3}; // 初始化
attempts = 4; // 赋值它们都可能使用 =,所以要根据语境判断:
int a = 7; // 这里的 = 参与初始化
a = 9; // 这里的 = 是赋值运算符int a = 7; // 拷贝初始化
int b(7); // 直接初始化
int c{7}; // 直接列表初始化
int d = {7}; // 拷贝列表初始化
int zero{}; // 值初始化,int 得到 0在新代码中,{} 很适合表达“这里在初始化”。它还会禁止一类窄化转换。
double ratio{1.5};
int count{ratio}; // 错误:可能丢失小数部分
bool ready{2}; // 错误:整数到 bool 是窄化转换如果改成 int count = ratio;,代码在许多编译设置下仍可通过,小数部分会被丢弃。列表初始化把问题提前到编译期,更适合默认使用。

下面的实验室可以切换目标类型和初始化形式,查看 C++17 规则会接受还是拒绝某个写法,以及通过后是否会丢失信息。
void process() {
int count; // 没有可依赖的初始值
int safe_count{}; // 0
}读取未经正确初始化的局部内置类型对象,会让程序进入无法依赖的行为。这不是“得到一个随机数”的正常技巧。
静态存储期对象有零初始化规则,类类型也可以由构造函数提供默认状态。但这些规则不是把局部 int count; 留空的理由。
count += 2; // 相当于用旧值加 2 后赋回
++count; // 增加 1count += 2 比 count = count + 2 更直接地表达“在现有数量上增加”。不过,简洁不能取代边界检查;整数运算仍可能溢出。
不要通过运行未初始化变量来“观察默认值”。某次看到 0 、某个固定数或每次不同的数,都不会让程序变正确。
来自表单、文件或网络的数据,即使暂时以 double 保存,也还不是可信的业务数量。程序应先验证数据,再让它进入只接受合法票数的核心逻辑。这样,后续函数接收 int 时,不必反复猜测输入是否为 NaN、无穷大、带小数的数或越界值。
验证可以按从“数值是否可判断”到“业务是否接受”的顺序进行:
std::isfinite 排除 NaN 和正负无穷;顺序很重要。非有限值不适合参与普通的整数性和业务比较;业务范围也不能代替整数性检查,因为 6.5 虽然位于 [0, 20],却不是合法票数。
下面的入口函数只接纳有限、无小数部分且位于 [0, 20] 的输入:
#include <cmath>
#include <stdexcept>
int checked_ticket_count(double raw) {
if (!std::isfinite(raw)) {
throw std::invalid_argument{"ticket count must be finite"};
}
if (std::trunc(raw) != raw) {
throw std::invalid_argument{"ticket count must be an integer"};
}
if (raw <
static_cast<int>(raw) 位于所有检查之后,只负责把已经验证的数据交给强类型接口。安全性来自前面的有限性、整数性和业务值域验证,而不是转换语法本身。调用者拿到返回值后,可以直接依赖票数约束:
void reserve_tickets(int count);
const int count = checked_ticket_count(received_value);
reserve_tickets(count);业务入口和核心函数最好统一使用同一种有符号类型,例如都用 int 表示票数。仅仅把形参改成无符号类型,并不能拒绝负的外部输入;负值在到达接口时可能已经变成一个很大的正数。
若底层接口确实要求无符号类型,应先在有符号或更宽的表示中验证上下界,确认值不小于 0 且不超过目标类型与业务规则共同允许的上界,再进行显式转换。不要把“无法表示负数”误当成“已经验证非负”。
解析成功只说明文本能够形成一个数值,不说明它适合作为业务数据。解析层负责识别格式,边界函数负责建立有限性、整数性和值域约束。
const、constexpr 和 auto 解决的不是同一个问题:
int read_limit();
const int runtime_limit{read_limit()}; // 可由运行时结果初始化
constexpr int hard_limit{20}; // 编译期可求值const 适合表达“初始化后不应再改”。当值还要参与需要常量表达式的语境时,使用 constexpr。
constexpr int cents_per_yuan{100};
const int unit_price_cents{2599};只要一个局部对象在建立后不需要变化,就可以优先加上 const。这会缩小后续代码的可能状态,也能让意外赋值立即成为编译错误。
auto count = 3; // int
auto ratio = 0.75; // double
const auto limit = 20; // const int推导在编译期完成。count 的类型一旦确定就不会在运行时变成其他类型。因为类型需要从初始器中得出,所以不能只写 auto value;。
auto 不会根据变量名或业务含义选类型,它只遵循推导规则:
const int max_count{20};
auto copied = max_count; // int,顶层 const 没有保留
const auto fixed = max_count; // const int
const auto& alias = max_count; // const int&如果要保留别名语义,需要写 auto& 或 const auto&。单独的 auto 通常得到一份值。
auto title = "workshop"; // const char*
std::string name{"workshop"}; // std::string
auto text = std::string{"workshop"}; // std::string如果你真正需要字符串对象,直接写 std::string 往往更清楚。当类型很长、由函数返回值明确决定,或在泛型代码中难以手写时,auto 的优势更明显。
不要为了少写几个字符而隐藏业务上关键的类型。auto 适合让类型从初始器中自然显现;如果读者必须跳很远才能知道类型,直接写出来可能更好。
引用和指针都能让代码间接访问已有对象,但它们表达的约束不同。本文只处理指向已有对象的基础用法,不进入动态分配。
int score{80};
int& alias{score};
alias += 5; // 修改的是 scorealias 和 score 指向同一个 int 对象。引用必须在定义时绑定对象,之后不能改绑。
int other{100};
alias = other; // 把 other 的值赋给 score,不是让 alias 改绑 other只读访问可以用 const 引用:
const int& view{score};
// view = 0; // 错误:不能通过 view 修改 scoreconst 引用限制的是这个访问路径。如果原对象本来可修改,其他非 const 名字仍可以修改它。
int score{80};
int* pointer{&score}; // &score 取得 score 的地址
*pointer += 5; // *pointer 访问该地址上的 int指针本身也是对象。它的值可以改成另一个同类型对象的地址,也可以明确表示“没有目标”:
int other{100};
pointer = &other;
pointer = nullptr;只有当指针指向一个仍在生命周期内、类型正确的对象时,才能解引用。
if (pointer != nullptr) {
std::cout << *pointer << '\n';
}非空不等于一定有效。如果目标对象已销毁,指针可能仍然保留旧地址,但它已经悬空。
int value{7};
const int* read_only{&value}; // 不能通过指针修改 value
int* const fixed{&value}; // 指针不能改指向,可修改 value可以从变量名附近开始读:read_only 先遇到 *,它是指针,指向 const int;fixed 先遇到右边的 const,它自己是常量指针。
不要解引用未初始化指针、nullptr 或悬空指针。指针只保存地址,不会自动延长目标对象的生命周期。
作用域回答“这个名字在哪里可见”,生命周期回答“这个对象在什么时候存在”。两者经常有关联,但不是同一个概念。
int outer{10};
if (outer > 0) {
int inner{outer + 1};
std::cout << inner << '\n';
}
// std::cout << inner; // 错误:inner 已不在作用域内常见作用域包括:

static 的名字可见性和对象生命周期并不相同。内层作用域可以声明同名变量,但这会遮蔽外层名字:
int count{2};
if (count > 0) {
int count{10}; // 遮蔽外层 count
std::cout << count << '\n'; // 10
}
std::cout << count << '\n'; // 2这段代码有明确规则,却容易被人读错。除非作用域极小且意图清楚,避免遮蔽更稳妥。
int next_id() {
static int counter{0}; // 首次执行到此处时初始化一次
int result{++counter}; // 每次调用都创建新的局部对象
return result;
}result 是自动存储期对象,每次执行到定义时创建,离开函数时销毁。counter 是局部静态对象,名字仅在函数中可见,对象却存活到程序结束。这正是作用域和生命周期不同的例子。
int* broken_pointer() {
int local{42};
return &local; // 函数返回后 local 被销毁
}返回的地址当时可能不是零,但目标对象已经不存在,所以该指针悬空。相同问题也会发生在返回局部对象引用时。
用下面的追踪器逐行推进代码,可以同时观察当前可见的名字、仍然存活的对象和指针状态,并对比自动对象与局部 static 对象。
下面的程序用整数分保存金额,先检查外部数量,再做显式转换。会员折扣用 constexpr 命名,优惠券通过可空指针表达,合计则通过引用修改。整个过程不需要动态分配。
#include <algorithm>
#include <cmath>
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <string>
constexpr int max_tickets{20};
constexpr int member_discount_percent{10};
constexpr int cents_per_yuan{100};
int checked_ticket_count(double raw) {
if (!std::isfinite
输出为:
item=weekend workshop
count=3
total=65.18 yuan这份代码还可继续改进,但它已把几个关键边界说清楚了:数量是经检查的 int,金额在运算中保持整数分,不变的对象使用 const,可空参数在解引用前检查,转换发生在明确且范围可控的位置。
const?auto 推导出的类型是否真的符合意图?