上一篇咱们把环境折腾明白、跑出了第一个Hello World,这篇我打算把C++真正的“地基”过一遍。如果你刚接触C++,或者看完语法书就开始犯迷糊,这篇应该能救你一把。这里说的“地碁”不是让你背语法,而是把变量、类型、运算符、流程控制、函数、数组和字符串这些最常用的零件拆开揉碎,讲清楚“为什么是这样”。很多新手卡住,不是因为语法没背下来,而是不理解背后那条内存和底层逻辑的线。这篇适合两类人:一类是刚看完入门教程、想系统过一遍基础知识的人;另一类是写过点代码但经常被编译错误、乱码、越界问题折腾到崩溃的人。建议把这篇文章当成一个能直接实操的笔记本,跟着代码敲,遇到问题回头翻。
1. 变量和基础类型:C++的内存观
要先理解C++,绕不开一个词:类型。C++是一门静态强类型语言,意思是每个变量在诞生那一刻就必须明确自己的类型,而且之后不能随便换。类型决定了两件事:这个对象占多大内存、内存里的二进制数据应该怎么解释。二进制本身没有含义,同样的0和1,用整数类型读是97,用字符类型读是字母a,差别完全由类型决定。
1.1 为什么C++要强调类型
很多从脚本语言转过来的人会问:我直接写个变量不就行了吗,为什么非要声明类型?因为在C++的世界里,编译器需要根据类型来分配内存、指定操作方式。声明一个int,编译器就给你留4字节或8字节,声明一个double就留更多。类型就像内存容器上的标签,标签对了,读写就不会错。
我经常用一个例子解释给身边的人:内存就像一个酒店走廊,每个门牌号背后都有一间房。类型就是你手里的钥匙。int钥匙打开房间,按4字节一格的方式读数;double钥匙按8字节一格方式读数。如果你拿int钥匙去开double房间,读出来的往往是完全看不懂的数字,这就是解析错位的来源。
C++11引进了auto,看起来可以“不写类型”了,但auto只是让编译器替你做类型推断,类型依然存在且必须明确。所以越早建立“变量必须有类型,类型决定内存解释方式”这个观念,后面看指针、引用、模板就越轻松。
1.2 常用基础类型与取值范围
入门阶段不需要把标准里的所有类型背一遍,但下面这组是最常用的,我建议用代码打印一遍,而不是死记硬背。
| 类型 | 常见大小 | 用途 | 注意事项 |
|---|---|---|---|
| bool | 1字节 | 真/假 | 不要假定总是1字节,但实际平台基本如此 |
| char | 1字节 | 单个字符或小整数 | 可能带符号也可能不带符号 |
| short | 2字节 | 小范围整数 | 跨平台时留意范围 |
| int | 4字节 | 默认整数类型 | 循环、计数器首选 |
| long | 4或8字节 | 更大的整数 | 标准从不保证比int长 |
| long long | 8字节 | 更大的整数 | 计数超过20亿时优先考虑 |
| float | 4字节 | 单精度浮点 | 别用来算精确值 |
| double | 8字节 | 双精度浮点 | 默认浮点字面量类型 |
标准只保证了sizeof(char)等于1,其他类型在不同平台可能有差异,所以最稳的办法是直接用代码验证。
#include <iostream> #include <limits> int main() { std::cout << "int 大小: " << sizeof(int) << " 字节\n"; std::cout << "int 范围: " << std::numeric_limits<int>::min() << " ~ " << std::numeric_limits<int>::max() << "\n"; std::cout << "double 大小: " << sizeof(double) << " 字节\n"; return 0; }这个代码跑完,你对“类型”就有身体记忆了。很多老手写跨平台代码时,不直接假设int是4字节,而是用sizeof去验证,就是这个原因。
1.3 初始化,能用{}就不要磨叽
C++里初始化有两种常见姿势:int a = 0; 和 int a{0};。旧式等号初始化没什么错,但C++11之后我更推荐大括号初始化,因为它多了一层安全检查:窄化转换会被编译器直接拦下来。
我举一个很典型的例子:你写了一个double d = 3.14,然后想把它塞进int变量里。等号初始化通常会给出警告,但有些编译器警告一多你就忽略了;大括号初始化会直接报编译错误,因为把3.14塞进int会丢小数部分,编译器觉得这不是安全行为。
double d = 3.14; // int a{d}; // 编译错误:narrowing conversion int b = static_cast<int>(d); // 强制转换,明确表示“我知道会丢精度”这不是C++在跟你作对,而是在保护你。等到做项目的时候,你一定会感谢这种“不让你偷偷丢数据”的坚持。
变量命名也值得单独说一句。给变量起名时,宁可多敲几个字母,也别用a、b、temp这一类含义不明的名字。基础阶段养成习惯,后面读代码和排查问题都会快很多。
2. 运算符和表达式:坑都藏在“优先”里
运算符看起来简单,但新手最容易翻车的地方往往就是这里。不是因为你不会算,而是你没料到编译器“偷偷做了选择”。
2.1 算术与赋值:整数除法、溢出和类型转换
第一个经典问题:int除以int,结果还是int,小数部分被直接截断。7/2的结果是3,不是3.5。很多第一次写C++的人看到这个结果会怀疑人生,但这就是整数除法的规则。
int a = 7; int b = 2; std::cout << a / b << "\n"; // 3 std::cout << a / 2.0 << "\n"; // 3.5 std::cout << static_cast<double>(a) / b << "\n"; // 3.5想让除法得到浮点数,至少要有一个操作数是浮点类型。第二种写法的2.0是double字面量,所以整个表达式会被自动提升为double运算;第三种写作是显式转换,我看代码时最喜欢看到这种写法,因为它把意图写得明明白白。
溢出是另一个隐蔽的坑。int能表示的范围有限,最大值再加1就会溢出。带符号整数溢出在C++里是未定义行为,这意味着程序可能做出任何事。实际开发中,计数器从大数往下减、做累加时,都要想想有没有溢出的风险。
2.2 前置++与后置++:变化量还是旧值
单独写i++和++i,看起来没区别,都是把i加1。但放进表达式里,区别就出来了。i++返回的是自增前的旧值,++i返回的是自增后的新值。
int i = 5; int y = i++; // y = 5, i = 6 int z = ++i; // i = 7, z = 7这个细节在循环里通常不影响结果,因为循环体里很少用返回值。但如果写了类似a[i++] = x;和a[++i] = x;这样有副作用的表达式,处理错了就会得到完全不同的数据。我的实际建议是:不要在同一个表达式里对同一个变量自增两次,比如f(i++, i++),这类代码的行为在语言规范里很难预测,纯粹给自己挖坑。
2.3 比较、逻辑与三目运算符:短路与优先级
比较运算的坑和值判断有关。if (x == 1)判断相等,if (x = 1)则是赋值,表达式的值是1,条件恒真。赋值表达式作为条件在语法上合法,所以编译器不一定报错,只会给警告。但如果你忽略警告,这个bug会藏得很深。
写这一节时,我强烈建议你用下面这个思路看待优先级:别背表,遇到不确定就加括号。代码是写给人看的,括号虽然多打两个字符,但能让读代码的人不用来回翻优先级表。这里有个简化表,平时够用了:
- 括号()
- 自增自减++ --
- 乘除取模* / %
- 加减+ -
- 关系比较< > <= >=
- 相等判断== !=
- 逻辑与&&
- 逻辑或||
另外,&&和||都有短路行为。a && b,如果a为假,b根本不会执行;a || b,如果a为真,b也不会执行。这个特性可以用来做防御性判断,比如if (p != nullptr && p->data > 0),你不用担心p为空时访问data,因为短路会拦住后续操作。
3. 流程控制:程序第一次“长脑子”
写程序本质上就是让计算机根据条件做选择、反复执行某些步骤。流程控制这一块,语法很短,但实际设计难度不小。
3.1 if、else if 和 switch:分支的两种打开方式
if语句处理的是“条件持续变化”的情况,switch处理的是“同一变量与多个固定常量比较”的情况。
int score = 75; if (score >= 90) { std::cout << "优秀\n"; } else if (score >= 60) { std::cout << "及格\n"; } else { std::cout << "继续努力\n"; }新手容易漏写else,导致两个分支同时执行。注意if和else if的匹配:在C++里,else总是和最靠近它的未匹配if配对。想避免困惑,就把大括号养成肌肉记忆,别依赖缩进表达逻辑。
switch里最容易忘的是break。少了break,程序会“落空”(fall-through),从一个case直接钻进下一个case。有时候这是故意设计的,但新手阶段建议每个case都写break,default也写上。有人问default是不是必须的?不是,但写上能兜住意外输入,对调试友好。
一个我踩过很多次的细节:在switch的case里定义变量时,最好加一对大括号。因为case标签后面的变量作用域经常为人诟病,不加花括号容易出现“jump to case label”的编译错误。
3.2 for、while 和 do-while:三种循环的适用现场
for循环适合循环次数确定的情况。初始化条件、循环条件、更新表达式,三件事全写在头部,看代码时一目了然。while循环适合“不知道会循环几次,只看到条件为真就要继续跑”的情况。do-while这次有点特殊,它保证循环体至少执行一次,非常适用于输入校验。
打个比方:你让用户输入一个1到10之间的整数,如果输了范围外的数就让他重新输。第一次无论如何都得先拿到一个输入,再判断要不要重来,这正是do-while最舒服的场景。
int value = 0; do { std::cout << "输入一个1~10的数: "; std::cin >> value; } while (value < 1 || value > 10);for循环里的三个部分都可以省略,但分号不能少。常见死循环是怎么来的?循环条件永远为真,或者更新变量被遗漏。我见过不少例子,一开始条件写对,后面忘了--count,结果程序卡死。写完循环体后,一定要先问一句:循环变量到底在哪里变的?
3.3 break 和 continue:跳出还是跳过一次
break是提前结束整个循环,continue是跳过当前这次循环的后半段,进入下一次迭代。
for (int i = 1; i <= 10; ++i) { if (i % 2 == 0) continue; // 偶数跳过打印 std::cout << i << ' '; }这段代码会输出1 3 5 7 9。continue会让循环跳到更新表达式,而不是直接从头判断条件,所以for循环里用continue一般不会死循环。但如果while循环里用了continue,同时又忘记在continue之前更新循环变量,那就可能陷入死循环。
嵌套循环中,break只能跳出最内层那一层。想要一次跳出两层,常见做法是加一个标志变量,比如bool done = false;,内层满足条件时置为true并break,外层看到done再break。这种写法虽然啰嗦,但比goto“看起来友好很多”。
4. 函数:把代码装进可复用的盒子
函数的核心价值是复用和抽象。不用函数也能写程序,但一旦逻辑重复超过两遍,你的维护成本就会直线上升。
4.1 声明、定义、调用:顺序错了再简单也会翻车
C++是“顺序编译”的,函数必须先声明后调用。所谓声明,就是告诉编译器“有这么个函数,参数和返回类型是什么”,定义才是实现具体逻辑。
int add(int a, int b); // 声明 int main() { std::cout << add(3, 5) << "\n"; return 0; } int add(int a, int b) { // 定义,也叫实现 return a + b; }新手经常在主函数后面写函数定义,忘了在前面加声明,然后编译报错。解决办法很简单:把声明写在文件头部,或者干脆把函数定义放在调用它的地方前面。如果你看到报错信息里出现“use of undeclared identifier”,多半就是这类问题。声明里的参数名可以省略,int add(int, int);也能用,但最好还是写上参数名,因为它是给读代码的人看的。
4.2 值传递、引用传递、指针传递:三种传参怎么挑
函数传参的方式有三种:值传递、引用传递、指针传递。区别在于“函数内部操作的是不是外部变量本身”。
按值传递时,函数拿到的是一份拷贝,外部变量不受影响。这种写法安全,但拷贝需要成本,适合int、double、char这类小对象。按引用传递时,函数拿到的就是那个外部变量本身,可以直接修改它,而且不需要拷贝。按指针传递也能修改外部变量,但指针可能为空,所以函数内部通常会先做一次判空。
| 传递方式 | 会不会复制 | 能改外部吗 | 常见用途 |
|---|---|---|---|
| 值传递 | 会 | 否 | 小对象,只用值 |
| 引用传递 | 否 | 是 | 需要修改或避免拷贝 |
| 指针传递 | 否 | 是 | 兼容C风格、可能需要表示“空” |
还有一个非常常见的组合:const引用。这种方式既避免了拷贝,又保证函数不会修改参数。比如处理一个std::string对象时,直接按值传会把整个字符串复制一遍,低效。用const std::string&就不会复制,同时还能安全读取内容。我写代码时,对于大对象只读场景,基本都用const引用。
4.3 默认参数与函数重载
默认参数可以让调用方省略一些参数。void showInfo(std::string name, int level = 0); 调用时写showInfo("tom")或showInfo("tom", 3)都可以。默认参数有个规则:只能从右边开始连续提供,不能跳过中间的某个参数去设后面的默认值。
还有一个细节:默认参数通常写在声明里,定义处不要再写。如果你在声明和定义两处都写了默认值,编译器会认为重复定义,直接报错。我见过不少朋友在这个地方被绕了两回,所以单独拿出来提醒。
函数重载则是“同名不同参”。编译器根据实参的个数、类型去选应该调用哪一版。
void print(int value) { std::cout << "int: " << value << "\n"; } void print(double value) { std::cout << "double: " << value << "\n"; } print(3); // 调用 int 版 print(3.2); // 调用 double 版需要注意,返回值类型不同不能用来区分重载,因为调用时可能不接收返回值,编译器无法判断。重载不是越多越好,一堆半斤八两的版本只会让调用点读起来像猜谜。
5. 数组与字符串:批量数据的第一现场
处理一个变量很简单,处理十个一百个变量怎么办?数组就是最原始的“批量数据”方案。字符串看起来简单,实则是无数缓冲区漏洞的来源。
5.1 数组下标从0开始,越界却不一定当场爆炸
数组的下标从0开始,意味着声明int arr[5];能用的下标是0到4。arr[5]已经越界,这在C++里是未定义行为。未定义行为最坑的地方在于:它有可能正常运行一次,下一次却崩掉,再下一次干脆悄悄改掉相邻变量的值。
int arr[5] = {10, 20, 30, 40, 50}; for (int i = 0; i < 5; ++i) { std::cout << arr[i] << ' '; }为什么下标从0开始?因为数组下标本质上是“从首元素开始数几个位置过去”。第一个元素就是距离起点0个位置,所以是下标0。这个设计和指针计算天然匹配,所以语言和编译器都这么干。
入门阶段最好养成“明确边界”的习惯。我自己写循环时,会反复确认上限到没到数组大小。现代C++里,你也可以用std::array和std::vector这类容器替你做边界检查,但理解数组仍然很有必要,因为你迟早会碰到跟C代码打交道的场景。
5.2 C风格字符串与std::string
C风格的字符串就是char数组,靠末尾的'\0'表示结束。strlen返回的长度不包含这个'\0',但你拷贝字符串时,一定要给'\0'留出位置。很多缓冲区溢出就是这样来的:长度算错了,写入内容把'\0'的位置盖掉了,或者直接把数据写到了数组外面。
C++提供了std::string,可以直接用==比较内容、用+拼接、用size()取长度,不用手动管理'\0'。新手阶段,我强烈建议果断用std::string。
#include <iostream> #include <string> int main() { std::string name; std::cout << "输入名字: "; std::getline(std::cin, name); std::cout << "你好, " << name << ", 长度: " << name.size() << "\n"; return 0; }这里藏着一个输入流的老问题:如果前面用了std::cin >> something,再想用getline读取一行,必须先处理掉输入缓冲区里的换行符。具体做法是std::cin.ignore();,否则getline会立刻读到那个残留的换行,表现就是程序“跳过”了输入。
5.3 range-based for:遍历可以更省心
从C++11开始,遍历数组和容器有了一种更不容易出错的写法:范围for循环。它不需要你自己计算下标,也就天然少了很多边界犯错的机会。
int arr[] = {1, 2, 3, 4, 5}; for (int x : arr) { std::cout << x << ' '; }如果只是读取数组元素,这样写很安全。但如果你想在循环里修改元素,就必须给变量加引用,改成for (auto& x : arr)。不加引用的话,x是每个元素的拷贝,改了也没用。这个区别,初学者经常会忽略,结果发现数组里的值根本没变化。
对std::string也可以用范围for:for (char c : name) { std::cout << c; }。当变量类型比较长、你不想把类型名写全时,可以用auto,但要清楚它到底推断成了哪个类型。用auto不是魔法,它只是让编译器帮你写出你本来要写的东西。
6. 入门阶段最容易踩的四个坑
基础语法学得差不多了,真正耗时间的却是排查问题。我把自己刚学C++时踩过、也见过别人踩的坑集中放在这里,当作一张速查表用。
6.1 分号、重复定义和未初始化变量
没写分号、变量重复定义、用未初始化的变量,这三大问题占了新手编译报错的一大半。int total;之后直接cout << total;,这块内存里可能残留上一次程序留下的值,也可能是0,完全看运气。未初始化变量的读取是未定义行为,别再拿“跑出来是0”当理由。定义变量的同时就给初值,这个习惯越早养成越好。
6.2 把==写成=
if (x = 1)条件恒为真,if (x = 0)条件恒为假。赋值表达式本身有值,所以它不是语法错误,编译器一般只警告。如果你对警告习惯性无视,这种bug就能悄悄混过去。开启编译器警告后,通常会有类似“suggest parentheses around assignment used as truth value”的提示。看到这种提示,先别急着忽略,多半你真的写错了。
6.3 数组越界与输出乱码
输出一串乱码,经常不是程序“坏了”,而是它读到了不该读的内存。C风格字符串没有'\0'结尾就扔给输出流,它会在内存里一路找下去,直到碰到一个0值。找的过程可能读到了附近的垃圾数据,于是乱码出现。排查时先确认字符串是否以'\0'结尾,再确认循环有没有越界。用std::string能省掉一大半这种麻烦。
6.4 把编译器警告当成麻烦
我建聊聊一个所有新手都应该建立的观念:编译器警告不是噪音,它是在帮你提前看见问题。我自己写代码时,尽量把警告打开到最严格的程度。很多低级错误,例如变量写了没读、符号理解歧义、赋值当比较,都会被警告提前暴露。与其事后调试一晚上,不如一开始就正视警告。看到警告,第一反应应该是“这里可能有问题”,而不是“程序能跑就行”。
把警告当成错误来处理,虽然在初期会让你多花一点时间,但它教会你的那套思路,会在后面处理更复杂的问题时一直帮着你。毕竟基础打得越稳,后面学指针、类、内存管理的时候,需要回头补的地基就越少。