mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
2376 字
6 分钟
cpp回炉重造(一)
2026-10-21

CPP回炉重造(一)#

逆向自己的靶场,遇到cpp内存布局+红黑树,拼尽全力无法战胜,遂回炉重造,在此记录笔记。

一、switch结构#

首先存在一个贯穿的问题,不写break的话就会接着往下执行。

在分支非常多时,推荐用switch而不是if-else,因为编译器会内联优化成jump表,也就是ida里的label 44这种。

还有个跟就是不要在条件写赋值语句,比如grade=22,这是恒为true的,造成干扰了。

switch (grade) {
case 'A':
std::cout << "优秀\n";
break;
case 'B':
std::cout << "良好\n";
break;
default:
std::cout << "未知等级\n";
break;
}

二、cpp里的for循环#

比c多了个优势,比如遍历数组的时候

int array = {1,2,3,4,5}
for(int i:array){
std::cout<<i<<'\n';
}

读作”对 arr 里的每个元素 x 做……”。不用写下标、不用算长度、不怕越界——std::vector、std::string、数组、std::map 这些容器全都一套写法:

std::string s = "hello";
for (char c : s) { // 字符串按字符遍历
std::cout << c << "-";
}

它还有三种变体

for (int x : arr) // 拷贝每个元素(只读、元素小、用这个)
for (int& x : arr) // 引用,能改原数组里的元素
for (const int& x : arr) // 常引用,只读不拷贝(元素大、用这个)

三、auto#

auto的作用是让编译器帮我们猜类型。

直白一点的用处就是省一点事,如下:

std::vector<std::pair<std::string,int>> scores;
//不写auto版本
for(std::vector<std::pair<std::string,int>>::iterator it=scores.begin();it !=scores.end();++it){
/*······*/
}
// 写 auto 的版本:
for (auto it = scores.begin(); it != scores.end(); ++it) { /* ... */ }

循环能借助const以及引用达到拷贝与只读的效果。

但是auto有个大坑:会剥掉引用!

int x = 10;
int& ref = x; // ref 是 int&(引用)
int* ptr = &x; // ptr 是 int*
auto a = ref; // a 是什么?答案是 int!
a = 20; // 只改了 a,x 还是 10

核心就是:auto 按”值”推导:它把引用剥掉、把顶层 const 剥掉,得到一份独立拷贝。

所以要保留引用还是得显示用&

auto& b = ref; // b 是 int&,改 b 就是改 x
const auto& c = ref; // c 是 const int&,只读
const auto d = x; // d 是 const int

极客的三口诀:

  1. auto 默认是”拷贝一份”,得到的是普通值;
  2. 想改原对象就写 auto&;
  3. 只想读不想拷就写 const auto&。

四、重载:同名不同参#

int add(int a, int b) { return a + b; }
double add(double a, double b) { return a + b; }
std::string add(const std::string& a, const std::string& b) {
return a + b;
}
add(1, 2); // 调 int 版
add(1.5, 2.5); // 调 double 版
add(std::string("a"), std::string("b")); // 调 string 版

编译器根据调用时实参的类型挑最合适的那一版。规则:参数类型、个数、顺序不同,就能构成重载;只靠返回类型区分不行

重载的底层原理是name mangling

即编译器会把函数名和函数类型添加到符号里。比如 add(int, int) 可能变成 _Z3addii。这叫 name mangling(名字改编)。

1

五、默认值#

有个坑:默认值只能写在头文件里

foo.h
void draw(int width, int color = 0xFF0000);
// foo.cpp
void draw(int width, int color) { // 这里不要写 = 0xFF0000!
// ...
}

为什么这样?因为调用放编译的话,是看头文件的而不会去看.cpp。所以要写进头文件里。

有第二个坑:默认值和重载可能会有冲突。

void print(int n){}
void print(int n,int m=3){}
print(1);//这个时候就不明确调用哪个了

六、引用#

引用就是起别名,引用必须初始化

int x = 20;
int& ref=x;
ref=10
std::cout<<x; //输出10

底层就是指针

int x = 10;
int& ref = x; // 底层:ref 存着 x 的地址
void foo(int& r) { // 传引用,底层传的是地址
r += 1; // 编译器自动解引用
}

什么时候必须用指针?三个典型:可能为空(用 nullptr 表示”无”)、要重新绑定、指向动态分配的对象(new 出来的东西、容器里的元素换位置)。

七、值传递与引用传递#

值传递:传入参数的值是会被拷贝成副本并在函数作用域内使用

void add(int x){
x+=10
}
int a = 10;
add(1);
std::cout<<a;//还是10

引用传递:相当于传的是地址,直接操作内存

void inc_by_ref(int& n) {
n += 1; // 改的就是实参
}
int x = 10;
inc_by_ref(x);
std::cout << x; // 11

大佬的经验法则:按值传 int、double 这种”拷贝跟没拷一样便宜”的类型;按值传 std::string、std::vector 这种要拷贝内部数据的,一律 const&。 比如print_log(const std::string& msg) 。

一个大坑:不要返回局部变量的引用

int& bad() {
int local = 42;
return local; // 危险!函数结束 local 就没了
}

返回局部变量的引用 = 返回一个悬垂引用(dangling reference),函数一结束内存就被回收,用它是未定义行为。能返回值就返回值,现代编译器有 RVO(返回值优化)

八、inline函数#

函数调用有开销:压栈、跳转、返回。如果函数很小(比如 int max(int a, int b)),调用开销可能比函数体本身还大。inline 就是告诉编译器:“这函数别真的调用了,直接把它展开到调用处。”

inline int max(int a, int b) {
return a > b ? a : b;
}
int r = max(3, 5); // 可能被编译成 int r = (3 > 5 ? 3 : 5);

注意”可能”——inline 是建议不是命令,编译器有完全的自由裁量权:函数太大、太复杂、有递归,它就不展开了。反过来,没写 inline 的小函数编译器在 -O2 下也经常自动内联。现代编译器面前,inline 更多是”请求”,内不内联它说了算。

在c语言里,能够实现同一目的的是宏

#define MAX(a, b) ((a) > (b) ? (a) : (b))
int r = MAX(3, 5);

重复调用豁免权:

util.h
inline int twice(int x) { return x * 2; } // 放头文件,OK
int not_inline(int x) { return x * 2; } // 放头文件:多个 cpp include 就重复定义

九、类的规则怪谈#

小函数直接写在类里,类里写的成员函数默认是 inline 的,编译器可能直接展开。大函数放类外,用 :: 指出属于哪个类:

Circle.h
class Circle {
double radius_;
public:
double area() const; // 只声明
void setRadius(double r);
};
// Circle.cpp
#include "Circle.h"
double Circle::area() const { // Circle:: 表示"这是 Circle 的成员"
return 3.14159 * radius_ * radius_;
}
void Circle::setRadius(double r) {
if (r >= 0) radius_ = r;
}

这里预告一下cpp对象内存布局

2a6cacd00610a8ddc05781

成员变量按声明顺序排布,成员函数不占对象空间(所有对象共享同一份代码)

十、类的权限#

分为三种:protected,public,private

默认是private

class BankAccount {
private:
double balance_ = 0; // 私有:只有本类成员能碰
protected:
int account_no_; // 保护:本类 + 子类能碰
public:
void deposit(double m) { // 公开:谁都能调
if (m > 0) balance_ += m;
}
double balance() const { return balance_; }
};
关键字谁能访问典型用途
public任何地方对外接口:方法、构造、析构
protected本类 + 派生类给继承留的后门
private仅本类(含友元)内部数据、内部实现

这里有个工程习惯就是:重要数据放private,然后提供一个public函数接口来间接操作它。

struct结构体在cpp就是另一种类的表达形式,都可以作数据聚合,不过他的只有public这种。

十一、构造函数#

构造函数的特点:函数名和类名相同、没有返回类型。它在对象创建时自动执行。

class Circle {
double radius_;
public:
Circle() : radius_(1.0) {} // 默认构造
Circle(double r) : radius_(r) {} // 带参构造
};
Circle c1; // 调默认构造,radius = 1.0
Circle c2(5.0); // 调带参构造,radius = 5.0
Circle c3{5.0}; // C++11 起,花括号初始化同样可用

不写构造函数?编译器会硬塞一个默认的构造函数给你。

编译器会自动生成默认构造函数——什么都不做,成员变量保持不确定状态(int/double 是垃圾值)。所以”不写构造”不等于”自动帮你初始化好”。

两个例外:

  1. 成员带了类内初始值(int x = 0;),不写构造也会初始化成这个值;
  2. 成员是有构造函数的类类型(比如 std::string),自动调用它的默认构造。

初始化列表:

冒号后面用 成员(值) 的形式给每个成员赋初值。注意它和函数体 { radius_ = r; } 的本质区别:

Circle(double r) : radius_(r) {} // 直接初始化:一步到位
Circle(double r) { radius_ = r; } // 先默认构造,再赋值:两步

对 double 这种内建类型,两种写法差别不大;但成员是类类型(std::string、std::vector)时差别立现——第一种直接构造一次,第二种先默认构造出一个空对象、再整体赋值,多了一次构造 + 一次赋值。能用初始化列表就用初始化列表,这是性能习惯,也是唯一能初始化 const 成员和引用的方式(大佬如是说):

class Config {
const int limit_; // const 成员
std::string name_; // 类类型成员
public:
Config(int l, const std::string& n)
: limit_(l), name_(n) {} // 只能在这里初始化 const 成员
Config(int l):limit_(l),name_("大厦春"){}
};

构造函数也是函数,所以能重载

十二、析构函数#

对象销毁时自动调用

class Logger {
FILE* file_;
public:
Logger(const char* path) : file_(fopen(path, "w")) {}
~Logger() { // 析构函数
fclose(file_); // 销毁时自动关文件
}
};
//栈对象的析构时会自己调用的
void process() {
Logger log("run.log"); // 构造:打开文件
do_something();
if (error) return; // 提前返回?
do_more();
} // 无论怎么离开,log 在此析构,文件被关
//堆对象则需要手动delete
Logger* p = new Logger("run.log"); // 构造
// ...
delete p; // 析构 + 释放内存

忘了 delete 就泄漏:析构不执行、资源不释放。

所以现代 C++ 的态度是:优先栈对象,堆对象交给智能指针(std::unique_ptr 在析构时自动 delete),手工 new/delete 只在万不得已时用。

注意!!!

析构函数默认是 noexcept 的(C++11 起),在里面抛异常会导致 std::terminate 直接终止程序——因为析构往往发生在栈展开等”已经在处理异常”的路径上,再抛一个会炸掉异常机制。规则:析构函数只做不抛异常的清理。

十三、拷贝构造与拷贝赋值#

二者的区别

Circle c1(5.0);
Circle c2 = c1; // 拷贝构造:用 c1 创建一个新对象 c2
Circle c3;
c3 = c1; // 拷贝赋值:c3 已存在,把 c1 的值灌进去
//对应的两个函数签名
class Circle {
public:
Circle(const Circle& other); // 拷贝构造
Circle& operator=(const Circle& other); // 拷贝赋值
};

默认行为:逐成员拷贝#

对于简单函数,不写上面这两个函数,编译器自动生成:把每个成员按值复制一份。

Circle c2 = c1; // radius_ 拷贝,行为正确

但是对象成员里有指针就很危险了。

浅拷贝#

class Buffer{
char* data;
int len;
public:
Buffer(int n): data(new char[n]),len(n){}
~Buffer() { delete[] data_; }
}

Buffer a(100); Buffer b = a; 发生什么?默认拷贝把 data_ 这个指针的值复制过去——于是 a.data_ 和 b.data_ 指向同一块堆内存。这叫浅拷贝:

347c48582edab9880824e0a23f06388d7328ae28

深拷贝#

上面的修复方法就是自己写个拷贝逻辑

class Buffer {
char* data_;
int size_;
public:
Buffer(int n) : data_(new char[n]), size_(n) {}
~Buffer() { delete[] data_; }
Buffer(const Buffer& other) // 深拷贝构造
: data_(new char[other.size_]), size_(other.size_) {
std::copy(other.data_, other.data_ + size_, data_);
}
Buffer& operator=(const Buffer& other) { // 深拷贝赋值
if (this != &other) { // 自赋值保护
delete[] data_; // 先释放旧数据
size_ = other.size_;
data_ = new char[size_];
std::copy(other.data_, other.data_ + size_, data_);
}
return *this;
}
};

要点:拷贝构造”新开一块内存、逐字节复制”;拷贝赋值还要多两步——先释放自己的旧数据(否则泄漏)、检查自赋值(a = a 不炸)。

如果一个类自己写了析构函数(说明它管着资源),那么拷贝构造、拷贝赋值也大概率要自己写——因为它们仨经常一起出现:

析构函数、拷贝构造、拷贝赋值,三者要么都用默认,要么都自己写。

这就是 Rule of Three。违反的典型症状就是上面的双重释放。现代 C++ 更推荐的其实是不用指针成员、改用 std::string/std::vector/std::unique_ptr——它们自带正确的拷贝/移动语义,让 Rule of Three 大部分时候自动满足。这点在 STL 容器 和 RAII 篇会反复强调。

笔记内容来自学习网站:https://geek-doc.cn/ 之后的文章大概率也是这个,极客大佬就是牛皮

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

cpp回炉重造(一)
https://fatdog.20060113.xyz/posts/cpp-again/
作者
神秘大胖狗
发布于
2026-10-21
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00