简介:这是一份面向C++零基础学习者与进阶读者的讲义笔记,围绕入门、核心、提高三个阶段组织内容,帮助读者系统建立从语法基础到面向对象与泛型编程的知识框架。内容上从Visual Studio中创建项目、编写第一个Hello World程序讲起,覆盖注释、变量与常量、关键字与标识符命名规则、sizeof与整型浮点型字符型等数据类型,再延伸到数组、指针、结构体、类、运算符重载、模板与try-catch异常处理,形成较完整的知识链条。资源包内共1个PDF文件,大小约5.07MB,以图文讲义形式呈现,便于打印或分章节阅读复习。目前已有25997人学习下载,适合课堂配套、自学梳理与面试前查漏补缺,读者可借此快速掌握代码书写规范与常见易错点,并对照讲义示例理解内存占用、常量定义方式等细节,为后续深入学习设计模式与算法打下基础。
1. 从一行 Hello world 说起:为什么语法背得再熟,也解释不了它能跑
学 C++ 最容易踩的坑,是把入门当成抄语法清单:变量、常量、运算符一条条记进笔记,代码能跑,却说不清它为什么能跑。可只要把开头那行cout << "Hello world"拆开看,就会发现它同时牵扯四件事:头文件在预处理阶段被原地展开成几千行声明、名字cout要在命名空间里被查找到、main被运行时当作进程入口、每个变量在内存里到底占几个字节。这份 C++ 入门、核心、提高的讲义笔记,真正值钱的地方不在于列了多少关键字,而在于它把每个语法点都按「占多少内存、卡在编译哪一步、运行时发生什么」讲了一遍。搞懂这一层,后面学指针、类、模板就不再是死记,而是知道每个新特性是在补前面留下的哪个洞。
2. 从源文件到进程:iostream、命名空间和 main 在四段流水线里各站哪一步
一个.cpp文件变成能双击运行的程序,中间隔了预处理、编译、汇编、链接四道工序。入门阶段最容易忽略的是:你写的代码只占其中一小段,剩下的部分是别人早就写好、在链接阶段才被拼进来的。理解这条流水线,比记住#include <iostream>要敲几个字母重要得多。
2.1#include不是「引入库」,是把头文件原地倒进来
很多笔记把#include说成「引入输入输出库」,这个说法会让你在第一次遇到链接错误时完全懵掉。预处理阶段的真实动作是:找到iostream这个文件,把它的全部文本原样复制到你写#include的那一行位置上。几千行声明就这样进了你的源文件,形成一个「翻译单元」。
// demo.cpp #include <iostream> // 预处理阶段:这一行被整个头文件内容原地替换 using namespace std; #define DAY 7 // 预处理阶段:后面所有出现 DAY 的地方被文本替换成 7 // 注意,是文本替换,不是赋值,没有类型,也不做检查 int main() { // 这行注释在预处理阶段就被整体删掉了,不会进入词法分析 // 所以注释写得再长,也绝不会影响生成的可执行文件体积 cout << "Hello world" << endl; return 0; }#include只带来声明(告诉编译器「有这么个东西,类型长这样」),真正的定义躺在标准库里,由链接器在最后一步接上。这就是为什么头文件拼错或者库没链上时,报的错是「无法解析的外部符号」而不是「语法错误」——编译器从头到尾都没见过cout的实现,它只知道有这么个名字。
#define DAY 7同样发生在预处理阶段,是纯文本替换:没有类型、不参与作用域、调试器里看不到这个名字。后面第 3 章会讲为什么const在这个位置比它靠谱。
提示:报错阶段本身就是线索。预处理错误(找不到头文件)、编译错误(语法、类型)、链接错误(找不到符号)三类问题对应的排查方向完全不同,先看报错出现在哪个阶段,能省一半时间。
2.2using namespace std省掉的不是字母,是一次名字查找
std是标准库的命名空间,cout、endl、string全都住在里面。你不写using namespace std;,就必须写全限定名std::cout。这两种写法在生成代码上没有性能差别,差别在于编译器做名字查找时搜索的范围。
#include <iostream> // 不使用 using 指令,靠全限定名指明每个名字住在哪个命名空间 int main() { std::cout << "Hello world" << std::endl; // 明确告诉编译器:去 std 里找 return 0; }编译器看到非限定的cout时,会先在当前作用域找,再往外层找,找不到就去using指令引入的命名空间里找。using namespace std;等于把整个std的名字集合摊到你当前的作用域里,任何一个名字都能被直接命中——包括你没打算用的那几千个。
风险也在这里。你自己定义了一个count、distance、data之类常见单词时,很可能和std里的同名实体撞上,编译器报出一堆看不懂的「有多个重载匹配」或者「不明确的符号」。所以工程里的血泪经验是:头文件里永远不要写using namespace,因为它会被复制粘贴进所有包含它的翻译单元,把别人的作用域一起污染;.cpp文件里图省事写一句,是能接受的折中。
命名空间这个机制本身,是 C++ 在没有现代模块系统之前,给「两个库有同名函数」这个问题准备的后悔药。没有它,两个第三方库各有一个init(),你就只能靠改源码或者加前缀硬扛。
2.3main的返回值给了谁,system("pause")又是谁的命令
main不是一个普通函数,它是运行时约定的入口。操作系统加载完程序后,把控制权交给启动代码,启动代码再调用main;main返回的那个int,就是进程的退出码。
#include <iostream> #include <cstdlib> // EXIT_SUCCESS / EXIT_FAILURE 定义在这里 int main() { std::cout << "Hello world" << std::endl; return EXIT_SUCCESS; // 语义上等价于 return 0;,但意图更明确 }退出码的意义在自动化里才显出来:脚本里判断上一条命令成没成,看的就是这个值,0表示成功,非0表示失败。return EXIT_SUCCESS和return 0生成的东西一样,但前者在阅读时不需要读者去查「0 到底是成功还是失败」。
至于讲义里那个system("pause"),它不是 C++ 的一部分。system的作用是把一个字符串交给当前系统的命令行解释器去执行,pause是那个解释器认识的命令,作用是打一行「按任意键继续」然后挂在那里。它有两个问题:一是平台相关性,换个系统可能根本没有这个命令;二是它把程序的运行节奏交给了外部进程,属于典型的「为了在某个 IDE 里双击运行时窗口不闪退」而加的补丁。真正在调试器里跑或者从终端里跑的时候,这一步完全多余。
要理解这条流水线的价值,可以做个对照:把#include换成你自己写的一个声明,把cout换成自己实现的函数,你会发现「引用声明 + 链接实现」这套模式贯穿整个 C++ 的工程组织方式,头文件和库的分工就是它的具象化。入门阶段把这层想清楚,后面看到「前置声明」「静态库/动态库」「符号冲突」这些词,就不会觉得是玄学。
3. 变量与类型:为什么 C++ 要你先声明「这块内存占几个字节」
C 语言和 C++ 在这里做了一件和很多脚本语言相反的事:变量必须先有类型,编译器才知道给它分配多少内存、按什么规则解释这段内存里的比特。这不是啰嗦,而是「直接在内存上做计算」这件事的必然代价。
3.1 变量名只是一块内存的门牌号,编译完就没了
int a = 10;的完整动作是:在栈上划出 4 个字节,把整数 10 按补码写进去,然后把a这个名字绑定到这块内存。编译产物里,a这个名字基本消失了(只保留在调试符号里),取而代之的是相对于栈帧基址的一个偏移量。这也解释了为什么长变量名不会拖慢程序——名字是给人看的,机器只认地址。
#include <iostream> using namespace std; int main() { int a = 10; // 4 字节,写入 10 int b = a; // 拷贝的是值,不是名字:b 是另一块 4 字节内存 b = 20; // 改 b 完全不影响 a cout << "a = " << a << ", b = " << b << endl; // a = 10, b = 20 return 0; }讲义里写「创建变量时必须给初始值,否则会报错」。严格说,这不是语言规则,而是编译器在帮你兜底:局部变量如果不初始化,它占据的是栈上残留的数据,值是不确定的,读它就是未定义行为——可能这次是 0,下次是别的程序留下的垃圾。实际问题比「报错」更麻烦,因为它可能一次跑得对、一次跑得错,排查起来极其费劲。所以「定义即初始化」这条习惯,是从第一次踩坑开始就该养成的。
3.2sizeof是编译期算出来的,整型家族的差别只在字节数
sizeof不是函数,是运算符,而且绝大多数情况下在编译期就把结果算好了,不产生任何运行时指令。它对变量、对类型名都能用。
#include <iostream> using namespace std; int main() { cout << "short : " << sizeof(short) << endl; // 通常 2 cout << "int : " << sizeof(int) << endl; // 通常 4 cout << "long : " << sizeof(long) << endl; // 32 位下 4,64 位下可能 8 cout << "long long : " << sizeof(long long) << endl; // 通常 8 cout << "float : " << sizeof(float) << endl; // 4 cout << "double : " << sizeof(double) << endl; // 8 cout << "bool : " << sizeof(bool) << endl; // 1 return 0; }| 类型 | 典型字节数 | 典型取值范围 | 关键点 |
|---|---|---|---|
short | 2 | -32768 ~ 32767 | 跨平台稳定 |
int | 4 | 约 ±21 亿 | 最常见的默认选择 |
long | 4 或 8 | 随平台变化 | 最容易踩「换平台行为变了」的坑 |
long long | 8 | 约 ±9.2×10^18 | 需要大整数时用它 |
float | 4 | 7 位有效数字 | 字面量要加f后缀 |
double | 8 | 15~16 位有效数字 | 默认浮点字面量类型 |
long是这张表里唯一一个字节数不保证的类型:同样是long,在一种数据模型下是 4 字节,在另一种下是 8 字节。这不是设计缺陷,而是历史遗留——早期平台的字长不同,标准只规定了「short≤int≤long≤long long」这个大小关系,把具体宽度留给实现。真要写跨平台的序列化或二进制协议,long是应该被回避的类型,宁可写int32_t这类明确宽度的别名。
字节数背后是取值范围。n 个字节的有符号整数,范围是 -2^(8n-1) 到 2^(8n-1)-1;无符号则从 0 到 2^(8n)-1。这个公式解释了为什么int溢出不报错而是「绕回去」:补码运算本身不检查边界,超出范围的结果就是模 2^32 之后的值。
3.3 浮点、bool和两种常量的真实差别
浮点数不是「小数」那么简单。float用 4 字节表达一个近似值,牺牲精度换范围,7 位有效数字意味着3.14159265存进float后末尾几位就不准了;double用 8 字节换到 15~16 位。写float f = 3.14f;里的那个f后缀不是装饰,它告诉编译器「这个字面量按float处理」,否则3.14默认是double,赋值时会触发一次窄化转换。
#include <iostream> using namespace std; #define DAY 7 // 宏常量:预处理阶段文本替换,没有类型 int main() { const int month = 12; // const 常量:有类型,参与编译期检查 float f1 = 3.14f; // f 后缀把字面量确定成 float double d1 = 3.14; bool flag = true; // 本质是 1,占 1 字节 cout << f1 << endl; // 3.14 cout << sizeof(f1) << endl; // 4 cout << sizeof(d1) << endl; // 8 cout << flag << endl; // 1,bool 打印出来是 0 或 1 cout << DAY << " / " << month << endl; // 7 / 12 return 0; }const和#define在做同一件事,但机制完全不同:#define是预处理器的文本替换,DAY在编译器眼里根本不是个名字,出了错你看到的报错信息里是7而不是DAY,调试器里也打不出来;const int是一个真正的、有类型、有作用域的变量,只是被标记为不可修改,编译器能做类型检查、能进符号表、能参与重载决议。
那为什么不用#define?因为它会在你最想不到的地方翻车:#define SQUARE(x) x*x,写SQUARE(1+2)展开成1+2*1+2,结果是 5 而不是 9。宏没有类型、没有作用域、没有求值规则,它唯一的优势是「能塞进预处理阶段做条件编译」,用在常量定义上属于反向操作。
4. 字符编码与表达式求值:char的 1 个字节和++的前后之争
类型讲完,接下来是最容易被当成「背一背就行」的两块:字符怎么存、表达式怎么算。这两块恰恰是入门讲义里信息密度最高、也最容易记混的地方。
4.1char里装的不是字形,是编码
char ch = 'a';存进内存的不是字母 a 的样子,是数字 97。字符型变量占 1 个字节,也就是 8 个比特,刚好装得下 ASCII 码的 0~127 区间。打印的时候之所以能看到a,是因为输出函数按当前编码把 97 翻译成了对应的字形。
#include <iostream> using namespace std; int main() { char ch = 'a'; cout << ch << endl; // 按字符解释:输出 a cout << (int)ch << endl; // 按整数解释:输出 97 ch = 97; // 反过来也成立,直接赋编码值 cout << ch << endl; // 输出 a cout << sizeof(char) << endl; // 1 return 0; }关键规则有两条:单引号只能包一个字符,'ab'是错的;双引号包的是字符串,"a"是一个长度为 2 的字符数组(a加结尾的'\0')。这两条不是格式规定,而是类型系统在区分「一字节的整数」和「一段连续内存的首地址」——两者的内存布局完全不同,混用会得到非常诡异的输出。
ASCII 表本身分两段:0~31 是不可打印的控制字符,历史上用于控制打印机之类的外设(换行、回车、退格都在这一段);32~126 是可打印字符,键盘上能敲出来的基本都在这里。大写的 A 是 65,小写的 a 是 97,中间差 32,这个规律在手工做大小写转换时经常用得上。
4.2 转义字符是「一个字符」,不是「两个字符」
\n写在源码里是两个符号,但它在编译后是一个字节0x0A。转义序列的作用,是让源码能表达那些没法直接打出来、或者会和语法冲突的字符。
#include <iostream> using namespace std; int main() { cout << "\\" << endl; // 输出一个反斜线本身 cout << "\tHello" << endl; // 制表符 + Hello,跳到下一个制表位 cout << "a\tb" << endl; // 用制表位对齐两列 cout << sizeof("\n") << endl; // 2:换行符本身 + 结尾的 '\0' return 0; }sizeof("\n")等于 2 这件事值得停下来想一下:字符串字面量的类型是「字符数组」,长度包含末尾那个隐含的'\0'。所以哪怕内容只有一个换行符,实际也占两个字节。这个细节在做缓冲区大小计算时是硬性约束,少算一个字节就会越界。
常用的几个:\n换行、\t跳到下一个制表位、\\表示一个反斜线、\'和\"在需要表示引号本身时用、\0是数值 0,同时是 C 风格字符串的终止标志。\r(回车)和\n(换行)在终端里的历史分工是「回到行首」和「下移一行」,两者配合使用才是一整套动作,这个历史包袱至今还能在跨平台文本文件的换行差异里看到。
4.3 C 风格数组和string是两套完全不同的内存模型
同样叫「存一个 hello world」,两种写法的底层结构天差地别。
#include <iostream> #include <string> // 用 string 必须带这个头文件 using namespace std; int main() { // C 风格:一块字符数组,内容 + 结尾的 '\0' // "hello world" 是 11 个字符,再加上终止符,数组实际占 12 字节 char str1[] = "hello world"; cout << str1 << endl; // 输出到 '\0' 为止 cout << sizeof(str1) << endl; // 12 // C++ 风格:一个类对象,内部自己管理缓冲区长度 string str = "hello world"; cout << str << endl; cout << str.size() << endl; // 11,长度信息自己记着,不靠终止符 return 0; }C 风格字符串的核心是「约定」:函数不知道数组有多长,只能一个字节一个字节往后扫,直到碰到'\0'停下来。这个约定的代价是,一旦你手写的数组忘了留终止符位置,或者拼接时越界覆盖了它,程序会一直读到相邻内存里去,行为不可预测。string则把这个约定封装掉了,长度单独记录,扩容自己处理,赋值、拼接、比较都有明确的语义。
入门阶段用哪种都行,但要清楚自己在用什么。char str1[]是一个数组,sizeof得到的是数组总大小;string是一个对象,sizeof得到的是对象自身的体积(通常几十字节,和内容长度无关,内容在堆上)。把两者混着理解,就会得出「string 占 11 字节」这种结论。
4.4 整数除法、取模和++的求值顺序
这几条几乎是每份讲义都会写、但很少讲透的部分。
#include <iostream> using namespace std; int main() { int a = 10, b = 3; cout << a / b << endl; // 3:两个整数相除,向零截断,不四舍五入 cout << a % b << endl; // 1:取余数,要求两边都是整型 double d1 = 0.5, d2 = 0.25; cout << d1 / d2 << endl; // 2:至少一边是浮点,就按浮点算 // cout << d1 % d2 << endl; // 编译不过:取模只对整型定义 int a2 = 10; int b2 = ++a2 * 10; // 先自增:a2 变 11,b2 = 110 int a3 = 10; int b3 = a3++ * 10; // 先取值:b3 = 100,之后 a3 才变 11 cout << b2 << " " << b3 << endl; // 110 100 return 0; }整数除法的截断方向是「向零取整」,所以-10 / 3得到 -3 而不是 -4。这个规则和数学里的向下取整不一样,涉及负数时容易算错。除数为 0 在整型下是未定义行为,程序直接崩或者给出垃圾值,编译器不会替你拦。
++a和a++的区别在表达式里才显现:前置版本先把变量加一,再返回加完之后的值;后置版本先返回加之前的副本,再把变量加一。对内置类型,编译器通常会把后置版本优化成同样的指令,性能没差别;但对自定义类型重载了++的情况,后置版本必须真实地复制一份对象出来,那个成本是实实在在的。这也解释了为什么 C++ 里写迭代器循环时,习惯用++it而不是it++。
4.5 比较和逻辑运算的结果只有 0 和 1,而且会短路
比较运算符返回的是bool,打印出来就是1或0,这跟「真」「假」是一回事。
#include <iostream> using namespace std; int main() { int a = 10, b = 20; cout << (a < b) << endl; // 1 cout << (a == b) << endl; // 0 // 短路求值:左边已经能定结果,右边根本不执行 // 这就是 if (ptr != nullptr && ptr->value > 0) 能安全工作的原因 int count = 0; bool r1 = (count > 0) && (++count > 0); // 左边为假,++count 不执行 bool r2 = (count == 0) || (++count > 0); // 左边为真,++count 不执行 cout << r1 << " " << r2 << " " << count << endl; // 0 1 0 return 0; }&&和||的短路不是优化,是语言标准规定的语义。它让「先判空再访问」这种写法成为合法的防御手段:左侧一旦确定为假,右侧整个子表达式就不求值,空指针那部分根本不会被执行。很多人第一次写链表遍历时把它换成位运算符&和|,结果两侧都求值,空指针照样解引用,程序当场崩——这两个符号长得像,语义差了一个「是否求值」的距离。
赋值运算符那一族(+=、-=、*=、/=、%=)本质是「读-算-写」三步的缩写,a += 2等价于a = a + 2。对内置类型没有性能差异,对重载了运算符的类型则可能省掉一次临时对象构造。
5. 常见问题与排查:那些编译能过、跑起来才翻车的坑
入门阶段最消耗时间的不是写不出代码,而是代码写完了表现出「时对时错」的行为。下面这些坑的共同特点是:编译器不报错,语法完全合法,问题藏在语义层。
5.1 编译期就能发现,但容易被忽略的问题
现象:char ch = "a";报出一串看不懂的错误,或者float f = 3.14;编译通过但被警告。
原因:"a"的类型是字符数组(本质是指针),不能赋给单个char,两者的内存模型完全不同;3.14默认是double,赋给float是一次精度收窄的隐式转换,标准允许但很多编译器会给出警告。
解决:字符用单引号,字符串用双引号,先想清楚要的是「一个字节的编码值」还是「一段连续内存的首地址」;浮点字面量要float就加f后缀,把意图写明确,别指望编译器猜。
现象:使用string时报「未定义标识符」。
原因:iostream里没有string的完整定义,两者是不同的头文件,讲义里特地点了这一句正是因为它高频。
解决:单独#include <string>,不要指望间接包含。
5.2 编译通过,运行期才暴露的问题
现象:局部变量打印出来是个随机数,改一次代码数字就变一次,有时候又恰好是 0。
原因:栈上的局部变量不会自动清零,未初始化时读到的是上次遗留的字节。这是未定义行为,不是「随机但稳定」的值,所以同一份可执行文件多跑几次都可能不同。
解决:定义时就给初值。int a = 0;这一行的成本是零,收益是消除了整类「时对时错」的问题。
现象:10 / 3得到 3,除法结果莫名其妙丢精度;5 / 2 * 2得到 4 而不是 5。
原因:两个整型相除,结果先向零截断成整数,再参与后续运算。整个表达式的类型在运算开始前就定了,不会因为后面赋给double就中途改主意。
解决:想得到小数,至少让一边是浮点,比如10.0 / 3或者(double)a / b。顺序很关键,(double)(a / b)是先算完整数再转,结果还是 3。
现象:int a = 10; int b = a++ + ++a;在不同编译器上结果不一样。
原因:同一个表达式里对同一个变量多次修改,中间没有序列点隔开,这是明确的未定义行为。标准没有规定求值顺序,编译器怎么排都合法。
解决:拆成几条独立语句,一条语句只改一次同一个变量。这类问题没有「正确结果」可言,换个编译器就可能翻车,别去研究「为什么这台机器上是这个值」。
现象:cin >> str;输入hello world,只拿到hello;连续输入时后面的读取全部失效。
原因:>>的提取以空白字符为分隔,遇到空格就停,world还留在缓冲区里。另外,如果读到了类型不匹配的内容(比如往int里输入字母),输入流会置上失败标志,之后所有读取都直接跳过,直到标志被清除。
解决:要读整行用getline;在读取失败后清理状态、丢弃缓冲区里的残留内容,再继续后续读取。这个坑在写菜单循环时几乎必踩一次。
注意:判断一个 C++ 问题属于哪一类,先看它在哪个阶段暴露——预处理找不到文件、编译阶段报类型或语法、链接阶段报未解析符号、运行阶段表现异常。四类问题的排查路径完全不同,混在一起找会浪费大量时间。
6. 取舍:入门讲义该先讲语法还是先讲内存模型
写入门材料有个绕不开的取舍:是把语法点铺满,让读者快速「感觉什么都能写」,还是从内存模型讲起,让人慢一点但每一步都踩实。前者上手快、挫败感低,代价是学完指针和引用之后会发现自己对「值从哪来、存到哪去」毫无直觉;后者开头几章偏硬,但后面每引入一个新特性,都能挂到已有的模型上。
这份讲义选了后者:先让你看sizeof打印出每个类型的字节数,再讲 ASCII 表,再讲转义序列的sizeof("\n")是 2,最后才进运算符。顺序不是随意的,它是按「内存里有什么 → 字节怎么解释 → 表达式怎么改这块内存」这条线走的。这个顺序的代价是前两章读起来不像「编程入门」,更像在看一张内存地图。
验证这套顺序有没有用,有个很朴素的方法:随便挑一个语法点,问自己三个问题——它占多少内存、它在编译的哪一步被处理、它在运行时会做什么。能答上来,说明这个点你理解了;答不上来,翻回对应的章节,多半能找到答案。比如#define:占多少内存(不占,预处理阶段就没了)、编译哪一步(预处理)、运行时做什么(什么都不做,因为它已经不是代码了)。三个问题串下来,宏和const的差别不用背就清楚了。
进阶的方向也很清楚:当你能把「1 个字节」「1 次求值」「1 个符号解析」这三把尺子同时用上,指针为什么能越界、虚函数表存在哪、移动语义省掉了什么拷贝,都不再是需要单独记忆的知识点,而是同一套模型在不同层级上的展开。
我自己的习惯是,每接触一个新的语言特性,先在草稿纸上画一下它的内存布局和生命周期,画不出来就说明还没懂,代码写出来也是蒙的。当年我第一次写链表,因为节点里那个指针初始化没做,遍历时崩溃得莫名其妙,查了两天才发现是创节点时少写了一行。从那以后我每次定义变量都强制自己把初值写上,哪怕就是= 0,也算给未来的自己留一粒后悔药。希望帮到你。
本文还有配套的精品资源,点击获取