前言C 是一门入门快、精通慢的语言。写一个能跑的Hello, World只要五行但同一个人写出的程序可能包含三处未定义行为undefined behaviorUB在测试机上一切正常换台机器就崩。这不是能力问题而是 C 的特性——它把很多在其他语言里由运行时兜底的检查交给了程序员和编译器警告。新手最常见的三个认知偏差是以为 C 就是C 加个cout。于是继续用 C 的数组、char*、手动malloc/free写 C完全错过了引用、std::string、std::vector、RAII 这些真正让 C 好用的东西。以为先学会写代码就行编译警告可以不管。实际上 C 里大量看起来能跑的代码是 UB编译器警告是免费的安全网关掉它等于闭眼开车。以为语法细节可以以后再补。像未初始化变量、和{}初始化这类细节是新手写出诡异 bug 的主要来源越早建立正确习惯越省事。本文按程序长什么样 → 类型与变量 → 流程控制 → 函数 → 引用与指针 → 标准库容器与字符串 → 类与对象 → 编译链接的顺序把入门阶段必须建立的正确概念过一遍。所有代码基于 C17可在 GCC 13 / Clang 17 / MSVC 19.3x 上编译。一、程序的骨架与编译流程// hello.cpp #include iostream // 引入标准库的输入输出设施 int main() { // 程序入口返回类型必须是 int std::cout Hello, C std::endl; return 0; // main 的 return 可以省略隐含返回 0 }编译运行g -stdc17 -Wall -Wextra -o hello hello.cpp ./hello从源代码到可执行文件要经过四个阶段阶段做什么常见错误预处理展开#include、#define处理条件编译头文件路径不对、宏没加括号编译把源文件翻译成汇编/目标文件语法错误、类型不匹配、未声明标识符汇编汇编代码转成机器码目标文件较少出错链接合并目标文件与库解析符号引用未定义引用undefined reference、重复定义编译错误和链接错误要分清楚前者是单个源文件里的问题后者是多个目标文件之间对不上号的问题。最常见的链接错误是声明了函数但忘了写定义——编译器看到声明就放行了链接阶段才发现找不到实现。二、类型、变量与初始化2.1 基本类型类型典型大小64 位平台用途bool1 字节真假char1 字节单个字符/字节int4 字节通常的整数long long8 字节大整数float/double4 / 8 字节浮点数默认用doublestd::size_t8 字节大小、下标无符号这些大小是平台相关的标准只规定了相对关系例如sizeof(char) 1、sizeof(int) sizeof(short)没有规定int一定是 4 字节。需要精确宽度时用cstdint里的std::int32_t、std::uint64_t这类定宽类型。2.2 初始化的两种写法int a 3.9; // ⚠️ 隐式窄化a 得到 3编译器可能只给警告 int b{3.9}; // ❌ 编译错误列表初始化禁止窄化转换 int c{3}; // ✅ 推荐写法 std::vectorint v{1, 2, 3}; // 三个元素 std::vectorint w(3, 7); // 三个元素每个都是 7{}初始化列表初始化是推荐的默认写法因为它禁止窄化转换能在编译期拦住把浮点数悄悄塞进int这类错误。注意最后两行的区别{1,2,3}是初始值列表(3,7)是3 个 7。2.3 未初始化变量int x; // ❌ 未初始化 std::cout x; // UB读到的是不确定的值读取未初始化的自动存储期变量是 UB未定义行为标准不保证任何行为。它不会自动等于 0那是某些语言的规则不是 C 的。养成声明即初始化的习惯int x 0; // ✅ int y{}; // ✅ 也是 0三、流程控制、函数与引用指针3.1 流程控制C 的控制流语句和 C 基本一致if/else、switch、for、while、do...while、break、continue。这里只说三个新手最常出问题的点。第一范围forrange-based forC11 起应当优先使用std::vectorint v{1, 2, 3}; for (int x : v) { // 拷贝每个元素 std::cout x ; } for (const int x : v) { // 只读引用避免拷贝 std::cout x ; } for (int x : v) { // 可修改引用 x * 2; }对大对象std::string、自定义类一定要用const否则每次迭代都拷贝一次。第二switch里别忘了breakswitch (code) { case 1: handleOne(); break; // 漏了这个 break 会贯穿到 case 2 case 2: handleTwo(); break; default: break; }第三浮点数不要用比较double a 0.1 0.2; if (a 0.3) { /* 几乎不可能成立 */ } // ❌ if (std::abs(a - 0.3) 1e-9) { /* ✅ */ } // 用容差比较浮点数的二进制表示无法精确保存大多数十进制小数0.1 0.2不等于0.3。需要容差比较std::abs来自cmath。3.2 函数参数传递与返回值#include iostream #include string #include vector int add(int a, int b) { // 传值 return a b; } void scale(std::vectordouble v, double factor) { // 传引用可修改 for (double x : v) { x * factor; } } double average(const std::vectordouble v) { // 传常量引用只读且无拷贝 if (v.empty()) return 0.0; // 必须先处理空容器 double sum 0.0; for (double x : v) sum x; return sum / static_castdouble(v.size()); }选择规则很简单小类型int、double、指针传值大对象只读用const需要修改用。两个红线不要返回局部变量的引用或指针。函数返回时局部对象已销毁之后访问是UB。非void函数的所有分支都要return。漏掉是UBmain除外它隐含返回 0。3.3 引用与指针对比项引用T指针T*能否为空不能必须绑定一个对象可以是nullptr能否重新绑定不能可以指向别处语法r 5;像普通变量*p 5;需要解引用常见用途函数参数、返回值可空、可重新指向、动态分配int x 10; int r x; // r 是 x 的别名 r 20; // 等价于 x 20 int* p x; // p 保存 x 的地址 *p 30; // 等价于 x 30 p nullptr; // p 不再指向任何对象 // 解引用空指针是 UB // *p 1; // ❌ 这是 UB标准不保证任何行为通常是崩溃引用在参数和返回值场景更安全因为不存在空引用和未初始化引用。新手应当优先用引用只有在确实需要可空或需要重新指向时才用指针。关于new/delete现代 C 中不应在业务代码里直接使用它们。用std::vector这类容器管理动态数组用智能指针std::unique_ptr、std::shared_ptr定义在memory管理单个对象让析构函数自动释放资源——这就是 RAIIResource Acquisition Is Initialization资源获取即初始化。四、标准库容器与类入门4.1std::string与std::vector#include iostream #include string #include vector int main() { std::string name C; // 不需要手动管理内存 name 17; std::cout name.size() \n; // 5 std::vectorint v; // 动态数组 v.push_back(1); v.push_back(2); v.emplace_back(3); // C11 起原位构造 std::cout v.size() \n; // 3 v[0] 100; // ⚠️ 不做边界检查 v.at(1) 200; // ✅ 越界会抛 std::out_of_range return 0; }operator[]不做边界检查越界访问是UBat()会检查并抛出std::out_of_range异常定义在stdexcept。开发阶段用at()定位问题确认安全后可以换回[]。std::vector的扩容策略增长因子是实现定义的libstdc 与 libc 通常按 2 倍增长MSVC STL 通常按 1.5 倍增长。这是各家实现的行为不是标准规定不要写成可移植的断言。4.2 类与对象入门#include iostream #include string class Student { public: Student(std::string name, int id) // 构造函数 : name_(std::move(name)), id_(id) {} // 成员初始化列表 const std::string name() const { return name_; } // const 成员函数 int id() const { return id_; } void rename(const std::string newName) { name_ newName; } private: std::string name_; // 命名后缀 _ 区分成员与参数 int id_; }; int main() { Student s(Alice, 1); std::cout s.name() \n; return 0; // s 在此自动析构 }三个要点成员初始化列表比在构造函数体内赋值更合适它直接构造成员而不是先默认构造再赋值而且const成员和引用成员只能用初始化列表初始化。const成员函数写在小括号后表示不修改对象的可观察状态这样const Student也能调用它。public/private是访问控制class默认privatestruct默认public。实战一个完整的小程序下面这个 C17 程序把上面讲的要点串起来std::vector存数据、const传参、范围for、类和const成员函数。// 文件beginner_demo.cpp // 编译g -stdc17 -Wall -Wextra -o beginner_demo beginner_demo.cpp #include cstddef #include iostream #include stdexcept #include string #include vector class GradeBook { public: void add(const std::string name, int score) { if (score 0 || score 100) { throw std::out_of_range(score must be in [0, 100]); } names_.push_back(name); scores_.push_back(score); } std::size_t size() const { return scores_.size(); } double average() const { if (scores_.empty()) { return 0.0; } double sum 0.0; for (int s : scores_) { sum s; } return sum / static_castdouble(scores_.size()); } // 返回最高分对应的名字空容器返回空字符串 std::string topStudent() const { if (scores_.empty()) { return {}; } std::size_t best 0; for (std::size_t i 1; i scores_.size(); i) { if (scores_[i] scores_[best]) { best i; } } return names_[best]; // 按值返回不是引用 } private: std::vectorstd::string names_; std::vectorint scores_; }; int main() { GradeBook book; book.add(Alice, 88); book.add(Bob, 95); book.add(Carol, 72); std::cout count book.size() \n; std::cout average book.average() \n; std::cout top book.topStudent() \n; try { book.add(Dave, 150); // 触发异常 } catch (const std::out_of_range e) { std::cout rejected: e.what() \n; } return 0; }期望输出count 3 average 85 top Bob rejected: score must be in [0, 100]注意topStudent()按值返回std::string而不是返回names_[best]的引用。这里返回引用其实也安全names_的生命周期跟对象一样长但按值返回的接口更不容易在后续重构中变成悬垂引用。常见坑点读未初始化的变量❌int x; if (x 0) { ... }—— 这是UB标准不保证任何行为x不是 0。✅int x{};或int x 0;声明时就给定初值。开启-Wall -WextraMSVC 用/W4并把警告当错误处理是入门阶段性价比最高的一条纪律。初始化和{}初始化混用❌int a 3.9;—— 悄悄窄化成 3编译器可能仅给警告。✅int a{3};。列表初始化禁止窄化写错会直接编译失败。vector构造的两种圆括号/花括号写反❌std::vectorint v{3, 7};以为得到3 个 7 —— 实际得到两个元素3和7。✅ 要3 个 7用std::vectorint v(3, 7);要初始值列表用花括号。范围for里拷贝大对象❌for (std::string s : names)—— 每次迭代都拷贝一个字符串。✅for (const std::string s : names)。在范围for中修改容器大小❌for (int x : v) { if (x 0) v.push_back(0); }—— 迭代器/引用失效行为是UB。push_back可能触发扩容让正在使用的迭代器全部作废。✅ 先收集要插入的内容循环结束后再统一插入。vector用[]越界访问❌v[v.size()]——operator[]不做边界检查这是UB。✅ 开发阶段用v.at(i)越界抛std::out_of_range或在使用下标前判断i v.size()。比较浮点数用❌if (0.1 0.2 0.3)—— 浮点表示误差导致条件不成立。✅ 用容差std::abs(a - b) 1e-9。返回局部变量的引用❌std::string f() { std::string s x; return s; }—— 悬垂引用访问是UB。✅ 按值返回。总结知识点正确做法需要避免初始化int x{};、列表初始化依赖未初始化变量、窄化参数传递小类型传值、大对象const大对象按值传返回值按值返回有拷贝省略返回局部变量的引用UB内存管理容器 智能指针RAII裸new/delete越界访问先判边界或用at()operator[]越界UB浮点比较容差比较用命名空间写std::前缀头文件里using namespace std;编译选项-stdc17 -Wall -Wextra关掉警告三句话总结C 入门阶段最该建立的两个习惯是声明即初始化和永远打开编译器警告因为新手写出的大多数诡异 bug 都是未初始化读取和越界访问这类UB而标准对 UB 不保证任何行为——它不会给你一个可预期的结果第二个习惯是优先使用标准库std::string、std::vector、智能指针而不是手写 C 风格的数组和裸指针这不是高级技巧而是从第一天起就能少踩坑的默认选择最后{}初始化、const传参、const成员函数这三处语法细节很小但它们是 C 里把错误提前到编译期的主要手段值得在入门阶段就形成肌肉记忆。