cpp重学(二)
学习内容来自:https://geek-doc.cn/
一、this指针
成员函数被编译后,都会暗藏一个参数,也就是this指针
class Circle { double radius_;public: void setRadius(double r);};
// 编译器的视角(等价于):void Circle_setRadius(Circle* this, double r); // 隐藏参数调用 c.setRadius(5.0) 时,编译器传的是 &c。这个隐藏参数在函数里就叫 this——指向当前对象的指针。所以成员函数里写 radius_,编译器实际当 this->radius_ 用。
一般来说,我们都不需要显示的写this,只有当参数和成员变量同名的时候才需要
class Circle { double radius_;public: void setRadius(double radius_) { // 参数名和成员同名 this->radius_ = radius_; // 必须用 this-> 区分成员和参数 }};链式调用
this是地址,*this就是对象本身,只要成员函数返回对象本身就能实现链式调用
class Logger { int level_;public: Logger& setLevel(int l) { level_ = l; return *this; // 返回自己 } Logger& setPrefix(const std::string& p) { prefix_ = p; return *this; }};
Logger log;log.setLevel(2).setPrefix("[app]"); // 链式:一个一个接着调setLevel 返回 *this(Logger&),所以 .setLevel(2) 的结果还是那个 log,可以继续 .setPrefix(...)。iostream 的 std::cout << a << b 能连续写、std::string 的 s.append(...).append(...) 能连环调,背后都是这个套路。
注意返回类型:要返回 Logger&(引用),返回 Logger(值拷贝)会复制一份,链式调用就操作副本了。
const成员函数
this变成了const指针
具有两个作用:
- 告诉调用方,我不会去修改成员变量
- 只有const对象才能调用。
const Circle c; c.area();只有 const 成员函数能调(非 const 成员函数会尝试修改,编译拒绝)。
class Circle { double radius_;public: double area() const { // const 成员函数 return 3.14159 * radius_ * radius_; }};const 出现在函数名后面,表示”这个成员函数不修改对象”。此时函数里的 this 类型是 const Circle*——通过它只能读、不能写。试试写 radius_ = 1;,编译报错。
二、静态成员
静态成员变量
静态成员变量是类的属性,而不是对象的属性。
class Counter { static int total_; // 静态成员变量:声明 int id_;public: Counter() : id_(++total_) {} // 每个对象拿一个递增的编号 static int total() { return total_; } // 静态成员函数};
int Counter::total_ = 0; // 定义 + 初始化(C++17 前必须写)声明和定义分离:static 成员变量在类里只是声明,还得在类外定义一次(int Counter::total_ = 0;)。不定义就链接报错——这是新手常卡的地方。C++17 起可以偷懒:类内写 inline static int total_ = 0;,一步到位。
静态成员函数
静态成员函数前面也带 static,特点是不绑定对象、没有 this。因此:
- 它不能访问普通成员变量/函数(那些要经过
this才能定位); - 它只能访问静态成员(静态变量、其他静态函数);
- 它可以用类名直接调用,不用创建对象:
int n = Counter::total(); // 直接用类名调,不需要对象// Counter c; int n = c.total(); // 也能用对象调,但没必要应用场景
| 场景 | 例子 |
|---|---|
| 全局计数 | 创建的对象总数、生成的 ID 序号 |
| 共享配置 | 所有对象共用的阈值、默认值 |
| 工具函数 | Math::sqrt 式的纯计算(不碰对象状态) |
| 单例模式 | 全局唯一实例的获取入口 |
| 工厂方法 | 创建对象的入口(返回 std::unique_ptr) |
三、友元
友元就是将外部的一个函数或者类认为是朋友,允许它访问自己的成员变量,包括私有。
class Point { int x_, y_;public: Point(int x, int y) : x_(x), y_(y) {} friend double distance(const Point& a, const Point& b);};
double distance(const Point& a, const Point& b) { // 普通函数,但能访问 Point 的私有成员 x_ y_ return std::sqrt((a.x_ - b.x_) * (a.x_ - b.x_) + (a.y_ - b.y_) * (a.y_ - b.y_));}friend double distance(...) 声明在类里,意思是”distance 这个函数是我的朋友,允许它碰我的私有成员”。注意三件事:
friend声明放在private:还是public:段都行——它是”朋友名单”,不参与访问控制;- 被声明的函数不是成员函数,就是普通全局函数;
- 友元关系是单向的、不可继承的——你把它当朋友,它未必把你当朋友;朋友的”朋友的朋友”不算朋友。
它最正当的用途是重载运算符
class Point { int x_, y_;public: friend std::ostream& operator<<(std::ostream& os, const Point& p) { os << "(" << p.x_ << ", " << p.y_ << ")"; return os; }};这就是友元用得最多的地方——全局运算符重载需要访问私有成员(下一篇运算符重载的必备搭档)。同理还有 operator>>、operator+(两个对象相加,写成员函数只能左边是对象、右边是别的)等。
友元类就不多说了,用的少,声明方法和函数一样。
四、运算符重载
运算符是函数,我们可以通过重载来实现这个效果
class Vector2 {public: double x_, y_; Vector2(double x, double y) : x_(x), y_(y) {}
Vector2 operator+(const Vector2& other) const { // 成员函数形式 return Vector2(x_ + other.x_, y_ + other.y_); } bool operator==(const Vector2& other) const { return x_ == other.x_ && y_ == other.y_; }};
Vector2 a(1, 2), b(3, 4);Vector2 c = a + b; // 等价于 a.operator+(b)if (a == b) { /* ... */ }a + b 被编译器翻译成 a.operator+(b)——成员函数形式下,左操作数是 this,右操作数是参数。operator+ 就是函数名,+ 只是个记号。
两种写法
| 形式 | 写法 | 左操作数 | 适用场景 |
|---|---|---|---|
| 成员函数 | Vector2 operator+(...) | 必须是本类对象 | 左边总是自己的类型 |
| 全局函数 | Vector2 operator+(const Vector2&, const Vector2&) | 可以是任意类型 | 两边都可能不是本类 |
成员函数简单直接,但有个硬限制:左操作数必须是本类的对象。比如 2.0 * v(double 在左边)写成成员函数就不行——2.0 不是 Vector2 对象。这时用全局函数:
Vector2 operator*(double k, const Vector2& v) { // 全局 return Vector2(k * v.x_, k * v.y_);}Vector2 operator*(const Vector2& v, double k) { // 再写一个对称的 return Vector2(v.x_ * k, v.y_ * k);}
Vector2 r = 2.0 * v; // 调全局版本Vector2 s = v * 2.0; // 调对称版本经验法则:左操作数是本类 → 成员函数;左操作数可能是别的类型 → 全局函数。加减乘除(对称运算符)很多项目统一用全局函数,两边平等对待。
operator<<:为什么必须全局 + 友元
<<的左边是std::cout(ostream对象),不是你的类。所以只能写全局函数。但全局函数要读p的私有成员x_、y_
class Point { int x_, y_;public: Point(int x, int y) : x_(x), y_(y) {} friend std::ostream& operator<<(std::ostream& os, const Point& p) { os << "(" << p.x_ << ", " << p.y_ << ")"; return os; }};
Point pt(1, 2);std::cout << pt; // 输出 (1, 2)两个细节:
- 返回
std::ostream&(还是那个 cout),才能支持cout << p << " done"连环输出——和上一篇的*this链式同理; - 上面把函数体直接写在
friend声明里(类内友元定义),简洁常见。
| 能重载 | |
|---|---|
+ - * / %、== != < >、<< >>、[]、()、++ --、`&& |
规则:只能重载已有的运算符,不能发明新符号;::、.、?: 这类语义太内核的禁止重载。运算符重载没法改变优先级和结合性——a + b * c 永远先乘后加。
五、const成员函数
这个上面说了一些,就不多说了,主要看看它和重载
同参数列表,一个有 const 一个没有,按调用对象是否 const 选择版本:
class Buffer { char* data_;public: char& operator[](size_t i) { return data_[i]; } // 可写版本 const char& operator[](size_t i) const { return data_[i]; } // 只读版本};
Buffer b;b[0] = 'x'; // 调非 const 版,可以写
const Buffer& cb = b;char c = cb[0]; // 调 const 版,只读// cb[0] = 'y'; // 错误:const 版返回 const char&,不能写例外
mutable 的适用面很窄:线程安全用的锁、缓存、统计计数。日常代码里遇到 mutable 要警惕——它绕过了 const 契约,用的地方越多,const 承诺的可信度越低。标准库的 std::mutex 就常是 mutable(加锁本身不改变”逻辑状态”)。
被mutable修饰的成员变量即使在const函数底下也是可以被修改的。
class Cache { mutable int hit_count_ = 0; // mutable:const 里也能改 int compute() const { ++hit_count_; // 合法:hit_count_ 是 mutable return 42; }};六、继承
class Shape { // 基类(父类)protected: double area_ = 0;public: double area() const { return area_; }};
class Circle : public Shape { // 派生类(子类):Circle 继承 Shape double radius_;public: Circle(double r) : radius_(r) { area_ = 3.14159 * r * r; // 能访问基类的 protected 成员 }};class Circle : public Shape 读作”Circle 公有继承 Shape”。继承后:
Circle自动拥有Shape的成员(area_、area()),不用重写;Circle可以加自己的成员(radius_)和函数;Circle是Shape的一种——is-a 关系:Circle 就是 Shape。
面向对象术语:被继承的叫基类/父类(base class),继承的叫派生类/子类(derived class)。
三种继承方式
| 继承方式 | 基类 public | 基类 protected | 基类 private |
|---|---|---|---|
: public | 仍 public | 仍 protected | 不可访问 |
: protected | 降为 protected | 仍 protected | 不可访问 |
: private | 降为 private | 降为 private | 不可访问 |
三条规律:
private基类成员谁都继承不到(只能本类 + 友元用);public继承不降级,protected/private继承逐级降;- 日常 99% 用
public继承——protected/private继承是”实现复用”的冷门工具。
记不住表格没关系,记住结论:写 : public Base 就对了。胖狗批注:大佬就是大佬,连这里都细化了。
举个例子
class Base {private: int priv_;protected: int prot_;public: int pub_;};
class Derived : public Base { void f() { // priv_ = 1; // 错误:Base 的 private,Derived 也碰不得 prot_ = 2; // 合法:protected 是给子类留的后门 pub_ = 3; // 合法 }};继承与组合
// 继承:is-aclass Car : public Vehicle { ... }; // 车是一种交通工具
// 组合:has-aclass Car { Engine engine_; // 车拥有一个引擎 std::vector<Wheel> wheels_;};| 继承(is-a) | 组合(has-a) | |
|---|---|---|
| 关系 | 子类是一种基类 | 类拥有成员对象 |
| 复用 | 继承基类的接口和实现 | 调用成员对象的接口 |
| 耦合 | 强(基类改动影响全部子类) | 弱(成员可以换) |
| 适用 | 真的共享类型层级 | 只是”用到某个类的能力” |
经验法则:能用组合就组合,真的需要多态才继承。初学者容易一上来就继承(“我也想复用那个类”),结果把两个不相干的类绑死。先把 is-a / has-a 想清楚再动手。
七、派生类构造与析构顺序
构造顺序:先鸡后派
class Base {public: Base() { std::cout << "Base 构造\n"; }};
class Derived : public Base {public: Derived() { std::cout << "Derived 构造\n"; }};
Derived d; // 输出:// Base 构造// Derived 构造同时基类的构造函数还能在初始化列表里显式指定
class Derived : public Base {public: Derived(int x) : Base(x), extra_(x) {} // 基类构造写在最前面};Base(x) 在初始化列表里调基类的带参构造。规则:基类构造必须排在初始化列表最前(其实不写也会自动排在前面,编译器不让你乱序)。
析构顺序
析构则是反着来,先从派生开始。
先析构派生类自己的部分,再析构基类部分。顺序和构造正好相反,道理也对称:析构派生类时,基类部分还在(派生类的清理逻辑可能还要用基类的成员);等派生类的清理做完,基类才安心退场。如果先析构基类,派生类析构时基类成员已经没了,访问就是悬垂。
析构顺序的复杂场景:成员对象
类的成员对象和基类混在一起时,完整顺序是:
- 基类构造函数
- 成员对象的构造函数(按声明顺序)
- 类自己的构造函数体
析构完全倒过来:自己 → 成员对象(逆声明序)→ 基类。
class Derived : public Base { Member a_; // 成员对象 Member b_;public: Derived() : Base(), a_(), b_() {}};
// 构造:Base → a_ → b_ → Derived 函数体// 析构:Derived 函数体 → b_ → a_ → Base八、虚函数与多态
没有vitural的继承只能按照指针掉
class Shape {public: double area() const { return 0; } // 普通成员函数};
class Circle : public Shape { double r_;public: Circle(double r) : r_(r) {} double area() const { return 3.14159 * r_ * r_; }};
Shape* s = new Circle(5.0);std::cout << s->area(); // 输出 0 —— 调的是 Shape::area!s 是 Shape*,编译器编译这行时只看得到指针类型:调 Shape::area,输出 0。虽然 s 实际指向 Circle,但普通成员函数的调用在编译期就定死了——这叫静态绑定。
加了vitural才能在运行时决定
class Shape {public: virtual double area() const { return 0; } // 虚函数};
class Circle : public Shape { double r_;public: Circle(double r) : r_(r) {} double area() const override { return 3.14159 * r_ * r_; } // 重写};
class Square : public Shape { double s_;public: Square(double s) : s_(s) {} double area() const override { return s_ * s_; }};
Shape* shapes[] = { new Circle(5.0), new Square(3.0) };for (auto* s : shapes) { std::cout << s->area(); // 分别调 Circle::area 和 Square::area}virtual 把调用从”编译期按指针类型”改成”运行期按实际对象”——动态绑定,这就是多态。s->area() 执行时,通过 s 指向的对象找到它真正的类,调那个类的 area。
配套关键字 override(C++11):声明”我在重写基类的虚函数”,写错签名编译器立刻报错——防止把 area() 写成 aree() 这种静默错误。
多态的底层:虚表
这玩意在lz的靶场里好像很常见,特喵的。
动态绑定靠**虚表(vtable)**实现。带虚函数的类,每个对象里藏一个隐藏指针 vptr,指向一张”函数指针表”:
每个具体类一张虚表,表里是”这个类各个虚函数的真实地址”。调用虚函数 = 一次间接跳转(取 vptr → 查表 → 跳转)。代价:
- 每个带虚函数的对象多一个指针(8 字节)的大小;
- 每次虚调用多一次间接寻址(比直接调用略慢,分支预测也难命中);
- 虚函数基本无法内联。
所以”该用多态时用多态,别为没用处的 virtual 白付开销”。
这里讲的不是很细,等到了大佬的网站专家层面再作扩充。
总结:virtual 把成员函数的调用从编译期静态绑定变成运行期动态绑定,基类指针/引用调用的虚函数会在运行时按实际对象分发——这就是多态;底层是对象里藏 vptr 指向虚表,代价是每个对象 +8 字节、每次虚调用多一次间接跳转且难以内联;有虚函数的类,析构函数务必写 virtual,否则 delete 基类指针会跳过派生类的清理。
九、overide和final
overide重写:让编译器明白我在重写
多态依赖”派生类重写基类虚函数”。但”重写”是靠签名一致匹配的——一旦签名写岔了,编译器不吭声,你的函数悄悄变成了一个新函数:
class Base {public: virtual void draw(int x) {}};
class Derived : public Base {public: void draw(double x) { ... } // 本意是重写,但参数类型变了!};draw(double) 和基类的 draw(int) 签名不同,不是重写——Derived 偷偷多了个新函数。运行时通过基类指针调 draw(int),走的还是基类版本,多态失效,且编译零报错。
写上overide,编译器就会帮我们核对
class Derived : public Base {public: void draw(double x) override { ... } // 编译错误:没有可重写的基类函数};书写规范:
class Base {public: virtual void f(int) {} virtual void g() const {} virtual ~Base() {}};
class Derived : public Base {public: void f(int) override {} // 正确:重写 f void g() const override {} // 正确:const 也算签名一部分 // void f(double) override {} // 错误:签名不匹配};注意点:
- 重写时
virtual可以不写(基类已虚),但写override已成惯例; const是签名的一部分——基类g() const,派生类写g()(忘 const)就不是重写;- 基类析构函数是虚的,派生类析构自动重写它,不用也不能标
override。
final:到此为止
class Base {public: virtual void f() {}};
class Derived : public Base {public: void f() final {} // 1. 函数级:我重写了,但禁止再往下重写};
// class Sub : public Derived { void f() override {} }; // 错误:f 已 final
class Sealed final { // 2. 类级:这个类不许被继承};
// class X : public Sealed {}; // 错误:Sealed 是 final 的函数 final:派生类可以继承/重写到此为止,再往下的子类不能重写它;类 final:整类封死,谁也别想继承。后者常用于”设计上不该有子类”的类型(比如 std::string 的实现,虽然标准库未必这么标)。
十、抽象类和纯虚函数
纯虚函数
只给签名,没有实现
class Shape {public: virtual double area() const = 0; // 纯虚函数:= 0,不写实现};
class Circle : public Shape { double r_;public: Circle(double r) : r_(r) {} double area() const override { return 3.14159 * r_ * r_; } // 必须实现};virtual ... = 0 就是纯虚函数——只声明”有什么操作”,不给出实现。它的潜台词是:“Shape 这个概念没有统一的 area 算法,你来定义具体的形状类时,自己实现。”
含至少一个纯虚函数的类叫抽象类:
- 不能实例化:
Shape s;编译错误——“面积公式都没有,怎么算?” - 可以定义
Shape*指针 /Shape&引用(指向派生类对象); - 派生类必须实现所有纯虚函数,否则派生类自己也是抽象类。
抽象类规定的是”子类必须提供什么”。任何 Circle / Square 只要继承 Shape,就得给 area() 一个说法,否则连对象都建不出来——这是编译器帮你执行的接口契约。
void print_area(const Shape& s) { // 参数用抽象类的引用 std::cout << s.area() << "\n"; // 不管具体是 Circle 还是 Square}
Circle c(5.0);Square sq(3.0);print_area(c); // 多态:调用各自实现print_area(sq);这就是面向对象编程的核心组织方式:调用方只依赖抽象类(接口),不依赖具体类(实现)。以后新增 Triangle,print_area 一行不改——这叫开闭原则(对扩展开放、对修改封闭),大型项目解耦的根基。
胖狗注:有点像java的interface。儿子像老子了哈哈哈。
纯虚析构函数
class Shape {public: virtual ~Shape() = 0; // 纯虚析构};
Shape::~Shape() {} // 必须给实现!否则链接错误析构函数能写成纯虚(目的是把类变成抽象类,禁止实例化),但析构函数必须有函数体——因为派生类析构时会调用基类析构,没有实现就链接失败。写实现的方式是在类外 Shape::~Shape() {}。
| 风格 | 写法 | 用途 |
|---|---|---|
| 抽象基类 | 部分纯虚 + 部分实现 + 成员数据 | 提供公共代码 + 留扩展点 |
| 纯接口类 | 全部纯虚 + 无成员数据 | 纯约定,Java 的 interface 等价物 |
virtual f() = 0 是纯虚函数:只声明接口不提供实现,含它的类就是抽象类,不能实例化、但可以定义指针/引用指向派生类;派生类必须实现全部纯虚函数才能创建对象;抽象类把”子类必须提供什么”变成编译器强制执行的契约,调用方只依赖抽象类即实现解耦;纯虚析构函数必须有实现,否则链接报错。
部分信息可能已经过时









