C++零基础到工程实战(二) - 变量的本质就是内存

文章内容

C++ 里的变量不只是一个名字和一个值。它占用一段内存,类型决定这段内存的大小、其中二进制数据的含义以及可以执行的运算。它还受作用域限制,并在某个明确的时刻创建、释放。
C++ 没有垃圾回收,内存的申请和释放由代码控制,所以类型、数值、运算规则和生命周期需要放在一起理解。

一、变量定义介绍

1.1 从变量值“可改变”说起

变量用来保存程序运行期间会改变的数值。程序由数据结构和算法两部分组成,变量对应数据结构,变量之间的运算对应算法;所以变量实际上是在讲数据结构这一半如何落到内存上。
代码中的固定值无法在运行时改写,可变数据因此要存放在内存中。一个变量需要确定四件事:

  1. 用什么名字找到它
  2. 用什么类型解释它
  3. 它在内存中保存什么二进制数据
  4. 以及这个类型支持哪些修改方式。

“变量 = 有名字的对象”,“对象 = 特定类型的内存空间”。通过变量名取到的是值,在名字前加 & 才能取得对象所在的内存地址。程序中显示的是虚拟内存地址(64 位程序的地址数值通常较大,32 位程序最大约为 2G)。

变量、对象与内存地址的关系

二、定义、初始化与赋值

2.1 定义

C++11 之后可以使用 类型 变量名{ 初始值 }; 定义变量。例如在 int x{ 100 }; 中,int 指定类型,x 是名字,大括号里的 100 是初始值,分号结束语句。初始化发生在对象创建时;赋值则是对已经存在的对象改写数值x = 100。

大括号初始化会做更严格的类型检查,int x{1.3} 会因为从 double 到 int 的收窄转换而编译失败,另外变量名也有语法要求:不能以数字开头。

2.2 局部变量必须初始化

局部变量没有默认值。C++ 申请内存时不会特意清零,因为需要避免额外写入操作;但内存会被反复申请和释放,新变量取得的空间可能仍保留之前写入的内容。读取未初始化的局部变量,结果会受运行环境影响。

变量定义语法的四个部分与初始化、赋值的区别

三、类型决定内存大小

3.1 字节、位与表示范围

内存中全是二进制数据,同一串位模式需结合类型才有确定含义。一个字节有 8 位。本章使用的大小是:bool 1 字节、char 1 字节、int 4 字节、long long 8 字节、float 4 字节、double 8 字节。char 是单字节数值,范围为 -128~127;int 是 32 位;long long 是 64 位,带符号时可表示到 2^63 量级。

六种基础类型的内存大小与可表示范围

sizeof 用于取得对象或字面量占用的字节数。

cpp
// 代码位置 firstcpp.cpp 第 28—50 行    // 定义 int 类型的变量 x,初始化值为 100    int x{ 100 };    // 输出变量 x 的值    cout << "变量x的值为" << x << endl;    // 在变量名前加 & 取到该变量的内存地址    // &x 的类型是 int*,这里强制转换成 long long 再输出    cout << "变量x的内存地址" << (long long)&x << endl;    // 定义 int 类型的变量 z,初始化值为 200    int z{ 200 };    // 取 z 的地址并输出,用来和 x 的地址比较    cout << "变量z的内存地址" << (long long)&z << endl;    // sizeof 拿到变量占用的字节数,int 的结果是 4    cout << "int x 内存大小" << sizeof(x) << "字节" << endl;    // 定义 long long 类型的变量 bigint,初始化值为 0    long long bigint{ 0 };    // long long 占 8 字节,sizeof(bigint) 的结果是 8    cout << "long long bigint 内存大小" << sizeof(bigint) << "字节" << endl;

Visual Studio 2022 控制台的一次运行结果如下,地址会随运行改变:

plain text
变量x的值为100变量x的内存地址48516561748变量z的内存地址48516561780int x 内存大小4字节long long bigint 内存大小8字节

不加 (long long) 时,&x 默认按十六进制显示,形如 000000F32C15F5D4。本次运行中先定义的 x 地址比后定义的 z 小 32,两个变量之间存在一段未分配给它们的空间;地址的具体数值和相对大小由编译器的分配策略决定,换个编译器或换次编译就可能不同。

四、字面量

4.1 默认类型和后缀

代码中直接出现、没有变量名的值是字面量,它同样占用内存。整数字面量默认是 int,L 表示 long,LL 表示 long long;大写后缀可避免小写 l 与数字 1 混淆。小数字面量默认是 double,后缀 f 将它指定为 float。以后遇到 UL 一类写法也按同一规则理解:后缀指定字面量的类型。

字面量的默认类型与类型后缀

cpp
// 代码位置 testvar.cpp 第 22—39 行    // 整数字面量 123 和 -123,默认类型都是 int    cout << 123 << -123 << endl;    // 后缀 LL 把字面量指定为 long long 类型    cout << 123LL << endl;    // sizeof 也能作用在字面量上,123LL 占 8 字节    cout << "sizeof(123LL)=" << sizeof(123LL) << endl;    // 123 占 4 字节    cout << "sizeof(123)=" << sizeof(123) << endl;    // 后缀 f 把小数指定为 float 类型    float f1{ 1.3f };    // 1.f 表示小数部分为 0 的 float 字面量    f1 = 1.f;    // 2.0f 同样是 float 字面量    f1 = 2.0f;    // 3.3 默认是 double 字面量,赋给 float 会损失精度    f1 = 3.3;    // 不带后缀的小数默认是 double 类型,占 8 字节    double d1{ 1.4 };

运行结果为:

plain text
sizeof(123LL)=8sizeof(123)=4

这两个结果也说明,字面量的后缀会直接改变它的类型和内存占用大小。

五、算术运算

5.1 运算符与复合赋值

字面量和变量都能参与运算。算术运算符包括 + - * / %,其中 % 取余,可用来表达“每若干次执行一次”这类规则。比较运算符有 == != < > <= >=,逻辑运算符有 & | && || ^ ! ~。除了先乘除后加减,复杂表达式可以用括号明确计算次序,无需记忆整张优先级表。

z += 10 等价于 z = z + 10,-= *= /= 也是对应运算与赋值的紧凑写法。这种写法在画面处理里常见:按像素行整体前进时写成 u += 1920,比拆成 u = u + 1920 更直接地表达“在原值上累加”。++ 和 -- 分别等价于加一和减一。只做自增而不使用表达式的旧值时,可以统一写前置 ++z,避免额外的临时值,也不必分辨“取值”和“自增”的先后。

变量支持的运算符分类

cpp
// 代码位置 testvar.cpp 第 41—59 行    // 运算符支持字面量,括号里的结果在变量定义时就算好    int x{ 1 + 2 };    // x 的值为 3    cout << "x = " << x << endl;    // 定义变量 y,初始化值为 3    int y{ 3 };    // 变量之间也可以做运算,z 的值为 6    int z{ x + y };    // 复合表达式先乘后加,z 的值为 19    z = x * y + 10;    // 打印此时的 z,输出 19    cout << "z = " << z << endl;    // += 是 z = z + 10 的简写    z += 10;    // -= 是 z = z - 5 的简写    z -= 5;    // *= 是 z = z * 4 的简写    z *= 4;    // /= 是 z = z / 2 的简写    z /= 2;    // z++ 是 z = z + 1 的简写,先取值再自增    z++;    // z-- 是 z = z - 1 的简写    z--;    // ++z 先自增再取值,只用自增时结果与 z++ 相同    ++z;    // --z 先自减再取值    --z;    // 打印一轮复合赋值和自增自减之后的 z,输出 48    cout << "z = " << z << endl;

运行结果为:

plain text
z = 19z = 48

两次打印之间,z 依次是 19 → 29(+= 10)→ 24(-= 5)→ 96(*= 4)→ 48(/= 2);随后的四次自增自减互相抵消,所以最终仍是 48。

六、整数除法与类型转换

除法有两条必须同时考虑的规则:除数不能为 0;参与运算的类型会决定结果是否保留小数。

cpp
// 代码位置 testvar.cpp 第 61—75 行    // 定义 int 变量 u,初始化值为 0    int u{ 0 };    // 整数除法 10 / 2 得到 5    u = 10 / 2;    cout << "u = " << u << endl;    // 除数是字面量 0,部分编译器在编译阶段直接报错    //u = 10 / 0;    // 定义 int 变量 t,初始化值为 0    int t{ 0 };    // 分母是变量时编译期不知道它的值,编译通过,运行时崩溃    //u = 10 / t;    // 浮点数赋给 int,小数位被直接丢掉    u = 1.1;    cout << "1.1 u = " << u << endl;    u = 1.9;    cout << "1.9 u = " << u << endl;    // 整数除法 2 / 3 的结果是 0    u = 2 / 3;    cout << "2 / 3 u =" << u << endl;

6.1 除数为零

10 / 0 的除数是字面量,编译器能在编译阶段看出它有问题:Visual Studio 直接报错拒绝编译,g++ 给出一条 division by zero 警告后仍会生成可执行文件。如果除数来自变量,编译时不知道其运行值,代码一定通过编译,却会在变量值为 0 时异常终止。

6.2 转换

两个整数做除法,结果仍按整数计算:2 / 3 得到 0,4 / 3 得到 1,小数位直接丢失。浮点数转成整数也不做四舍五入,1.1 → 1、1.9 → 1、0.9 → 0。需要四舍五入时,要自行判断,大于 0.5 时加 0.5 再取整。

需要小数结果时,至少让一个运算数成为浮点数。1 / 2. 中的 2. 是 double 字面量;变量可以写成 x1 / (float)x2。(float)(x1 / x2) 的转换太晚,括号内部已经完成整数除法并得到 0,转成 float 后仍是 0。

整数除法的规则与类型转换的位置

cpp
// 代码位置 testvar.cpp 第 76—88 行    // 定义两个整型变量 x1、x2    int x1{ 1 };    int x2{ 2 };    // 定义 float 变量 f2,初始化值为 0    float f2{ 0 };    // 整数除整数结果仍是整数 0,赋给 float 也还是 0    f2 = x1 / x2;    cout << "f2 = " << f2 << endl;    // 字面量 1 / 2 同样是整数除法,输出 0    cout << "1/2 = " << 1 / 2 << endl;    // 2. 是 double 字面量,除法按浮点进行,输出 0.5    cout << "1/2. = " << 1 / 2. << endl;    // 把其中一个运算数转成 float,除法按浮点进行    f2 = x1 / (float)x2;    cout << "f2 = " << f2 << endl;    // 转换加在整个结果上无效:括号里已经算完整数除法得到 0    f2 = (float)(x1 / x2);    cout << "f2 = " << f2 << endl;

这段代码的输出依次为:

plain text
f2 = 01/2 = 01/2. = 0.5f2 = 0.5f2 = 0

例如用已播帧数除以总帧数计算播放进度,两个数往往都是整数,所需的结果却是百分比小数。除法前必须改变其中一个运算数的类型,这样才能保留小数部分。

七、作用域与生命周期

7.1 大括号划定可见范围

一对大括号划出一个作用域,内层代码块的范围更小。子作用域可以访问父作用域中的变量,父作用域不能访问子作用域的局部变量。同一作用域不能重复定义同名变量;不同作用域允许重名,内层名字会遮蔽外层名字,访问时按就近原则确定对象。

7.2 内存什么时候占用和释放

生命周期回答变量对应的内存从何时开始占用、何时释放。局部变量从定义所在行开始申请空间,离开所在的大括号时释放。
全局变量定义在入口函数之外,在进入 main 前已经申请空间,main 结束后释放;全局对象会做零初始化,因此未显式初始化的全局变量也是 0。

空间释放后便不能再通过原来的局部变量访问。将局部空间传到作用域外需要另外的内存管理方法,后续的动态内存和智能指针会处理这类需求。

作用域嵌套与变量的生命周期

cpp
// 代码位置 testvar.cpp 第 13—18 行、第 93—107 行// 全局变量定义在 main 函数外部// 作用域:不做声明的情况下,本文件内都可以访问// 生命周期:进入 main 之前申请空间,main 结束后释放空间int gx{ 0 }; int main(){    // 局部变量 px:作用域从这一行开始到 main 的右大括号结束    int px{ 0 };    // 一对大括号就是一段代码块,也是一个子作用域    {        // 子作用域里定义的 py,出了这对大括号就访问不到        int py{ 0 };        // 子作用域里可以再定义一个同名的 x,与外层互不影响        int x{ 0 };        // 子作用域可以访问父作用域的 px        cout << "px = " << px << endl;        // 也可以访问全局变量 gx        cout << "gx = " << gx << endl;        // 这里访问的是子作用域里的 x,遵循就近原则        cout << "x = " << x << endl;    }    // py 的空间已经释放,下面这行去掉注释后编译不通过    //cout << "py = " << py << endl;

运行输出为:

plain text
px = 0gx = 0x = 0

八、常量:const 与 constexpr

8.1 运行时常量和编译时常量

const int x{ 0 }; 定义运行时常量:它的值可以由运行期的结果确定,一旦定义便不允许修改。C++11 增加的 constexpr int cx{ 1 }; 定义编译时常量,其值必须能在编译期确定。可以把它理解为:代码中使用 cx 的位置,在编译时已经能对应到其值。

const 允许用外部输入或运行时计算的结果初始化,因此更适合承担“运行期确定”的角色;如果初始化表达式本身就是常量表达式,它同样可以出现在编译期场合。constexpr 不能由运行时变量初始化,可以用在数组下标、内存空间初始化等必须于编译期确定的位置。可以预先确定的值优先用 constexpr,涉及参数传递时用 const。

const 运行时常量与 constexpr 编译时常量对比

cpp
// 代码位置 testvar.cpp 第 110—124 行    // const 定义运行时常量,值在运行时确定,之后不允许修改    const int cx{ 100 };    // 取消注释会编译报错:表达式必须是可修改的左值    //cx = 200;    cout << "cx = " << cx << endl;    // 定义普通变量 t1,值为 10    int t1 = 10;    // 运行时常量可以由变量参与计算得到,cx2 的值为 20    const int cx2{ t1 + 10 };    cout << "cx2 = " << cx2 << endl;    // constexpr 定义编译时常量,值必须在编译期就能算出来    constexpr int cex{ 300 };    cout << "cex = " << cex << endl;    // 取消注释会编译报错:t1 不是常量表达式    //constexpr int cex2{ t1 * 10 };

运行输出为:

plain text
cx = 100cx2 = 20cex = 300

若取消 constexpr int cex2{ t1 * 10 }; 的注释,编译器会报 error: the value of 't1' is not usable in a constant expression,并指出 t1 不是常量;Visual Studio 的提示是表达式必须含有常量值,变量 t1 不可用作常量。

8.2 常量主要保护可维护性

常量定义时必须给出初始值,否则它既没有值,后续又不能改写。常量的主要价值是表达约束并让编译器检查误修改:阅读变量定义就能知道此值不可改,不必再写解释性注释;
函数参数加 const,也表示函数不会修改传入的对象。编译时常量可以带来效率收益,但约束信息对长期维护更有价值。

九、auto:从初始化器推导类型

9.1 推导规则与常量退化

auto 让编译器根据初始值推导变量类型,推导发生在编译期,不会增加运行时的额外开销。它与 PHP、JavaScript 在运行时处理类型的方式不同。auto 不能在没有初始值时推导,因此也强制变量在定义时初始化。
它还能缩短较长的类型名;函数返回类型变化时,用 auto 接收的位置不用同步改声明。泛型编程和 lambda 表达式中也经常使用它。

用常量初始化普通 auto 变量时,推导结果会去掉常量特性。auto acex = cex; 得到可修改的普通变量;要保留约束,需写成 constexpr auto c1 = cex; 或 const auto c2 = a1;。强制转换也会影响推导,auto a5 = (int)d1; 会把 a5 推导为 int,类型只需要写在转换处,声明处不用重复。对 auto 而言,= 和大括号初始化等效;普通变量的两种写法并不等效,因为大括号会检查收窄转换。

auto 的推导规则与常量退化

cpp
// 代码位置 testvar.cpp 第 125—135 行    {        // 从初始值 10 推导出 a1 是 int        auto a1 = 10;        // 从小数 9. 推导出 d1 是 double        auto d1 = 9.;        // 从后缀 f 推导出 f1 是 float        auto f1 = 8.f;        // 用常量初始化时 auto 只推导类型,常量特性会丢掉        auto acex = cex;        // 要保留常量特性就写 constexpr auto        constexpr auto c1 = cex;        // 也可以写 const auto        const auto c2 = a1;        // 大括号初始化对 auto 与等号等效,a3 是 double        auto a3{ 11.3 };        // 后缀 LL 让 a4 推导为 long long        auto a4 = 14LL;        // 强制转换后的类型会被 auto 采纳,a5 是 int        auto a5 = (int)d1;    }

局部变量可以在类型含义明确时使用 auto。声明更短不代表类型消失,类型已由初始化器在编译期确定。

十、用 YUV 与 RGB 示例

10.1 从输入到转换结果

这一章依次讲了变量的初始化和赋值、作用域与生命周期、编译时常量与运行时常量、auto 类型推导。这些知识点可以落到一组颜色空间转换上:变量负责保存输入和中间结果,浮点运算保留小数,类型转换决定最终整数怎样得到。

影像存储常用 YUV,其中 Y 表示亮度,U、V 表示色度;显示器使用 RGB,即红、绿、蓝三原色。cin >> x >> y >> z; 可一次读入多个数值。

YUV 转 RGB 使用以下公式:

  • R = Y + 1.13983 * (V - 128)
  • G = Y - 0.39465 * (U - 128) - 0.58060 * (V - 128)
  • B = Y + 2.03211 * (U - 128)

RGB 转 YUV 使用:

  • Y = 0.299 * R + 0.587 * G + 0.114 * B
  • U = -0.14713 * R - 0.28886 * G + 0.436 * B + 128
  • V = 0.615 * R - 0.51498 * G - 0.10001 * B + 128

计算结果还要做钳位:小于 0 时取 0,大于 255 时取 255。这部分需要的条件判断将在后续补全,当前可以先用变量、运算和转换完成两组公式。

第二章知识地图与 YUV、RGB 转换公式

完成这组公式时,每个变量的类型、初始值和转换位置都会影响结果;作用域决定它在哪里可见,生命周期则界定这段内存可以被访问的时间。

把两组公式写成程序,输入、中间结果和最终分量各自占一个变量。输入用 cin 一次读三个值,公式里带小数的运算结果保存在 double 里,只有写回整数分量时才做类型转换。

cpp
// 按作业公式写的完整示例,g++ 13.3 编译运行#include <iostream>using namespace std; int main(){    // 依次读入 Y、U、V 三个分量    int y{ 0 };    int u{ 0 };    int v{ 0 };    cin >> y >> u >> v;    // YUV 转 RGB,中间结果要保留小数,用 double 保存    double r{ y + 1.13983 * (v - 128) };    double g{ y - 0.39465 * (u - 128) - 0.58060 * (v - 128) };    double b{ y + 2.03211 * (u - 128) };    cout << "R = " << r << " G = " << g << " B = " << b << endl;    // 像素分量是整数,转换写在这里,小数位被丢掉    int ri{ (int)r };    int gi{ (int)g };    int bi{ (int)b };    cout << "R = " << ri << " G = " << gi << " B = " << bi << endl;    // RGB 转回 YUV,输入换成上一行得到的整数分量    double ry{ 0.299 * ri + 0.587 * gi + 0.114 * bi };    double ru{ -0.14713 * ri - 0.28886 * gi + 0.436 * bi + 128 };    double rv{ 0.615 * ri - 0.51498 * gi - 0.10001 * bi + 128 };    cout << "Y = " << (int)ry << " U = " << (int)ru << " V = " << (int)rv << endl;}

输入 100 120 140(依次是 Y、U、V)时,输出为:

plain text
R = 113.678 G = 96.19 B = 83.7431R = 113 G = 96 B = 83Y = 99 U = 119 V = 139

三行输出对应两次方向相反的转换。第一行是 double 保存的中间结果;第二行把分量转成整数,113.678 得到 113 而不是 114,(int) 只截掉小数位,不做四舍五入,和前面整数除法的行为一致;第三行用整数分量转回 YUV,得到 99、119、139,比输入的 100、120、140 各小 1。


本文由 AgentWorkFlow 生成

觉得不错?给作者点个赞吧

分类:c++

评论 (0)

无需注册,留言经站长审核后公开。

加载评论中…

C++零基础到工程实战(二) - 变量的本质就是内存 · Yinchen 的博客