mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
3940 字
10 分钟
重学cpp(二)
2026-10-22

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 这个函数是我的朋友,允许它碰我的私有成员”。注意三件事:

  1. friend 声明放在 private: 还是 public: 段都行——它是”朋友名单”,不参与访问控制;
  2. 被声明的函数不是成员函数,就是普通全局函数;
  3. 友元关系是单向的、不可继承的——你把它当朋友,它未必把你当朋友;朋友的”朋友的朋友”不算朋友。

它最正当的用途是重载运算符

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)

两个细节:

  1. 返回 std::ostream&(还是那个 cout),才能支持 cout << p << " done" 连环输出——和上一篇的 *this 链式同理;
  2. 上面把函数体直接写在 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”。继承后:

  1. Circle 自动拥有 Shape 的成员(area_、area()),不用重写;
  2. Circle 可以加自己的成员(radius_)和函数;
  3. Circle 是 Shape 的一种——is-a 关系:Circle 就是 Shape。

面向对象术语:被继承的叫基类/父类(base class),继承的叫派生类/子类(derived class)。

三种继承方式

继承方式基类 public基类 protected基类 private
: public仍 public仍 protected不可访问
: protected降为 protected仍 protected不可访问
: private降为 private降为 private不可访问

三条规律:

  1. private 基类成员谁都继承不到(只能本类 + 友元用);
  2. public 继承不降级,protected/private 继承逐级降;
  3. 日常 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-a
class Car : public Vehicle { ... }; // 车是一种交通工具
// 组合:has-a
class 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) 在初始化列表里调基类的带参构造。规则:基类构造必须排在初始化列表最前(其实不写也会自动排在前面,编译器不让你乱序)。

析构顺序#

析构则是反着来,先从派生开始。

先析构派生类自己的部分,再析构基类部分。顺序和构造正好相反,道理也对称:析构派生类时,基类部分还在(派生类的清理逻辑可能还要用基类的成员);等派生类的清理做完,基类才安心退场。如果先析构基类,派生类析构时基类成员已经没了,访问就是悬垂。


析构顺序的复杂场景:成员对象

类的成员对象和基类混在一起时,完整顺序是:

  1. 基类构造函数
  2. 成员对象的构造函数(按声明顺序)
  3. 类自己的构造函数体

析构完全倒过来:自己 → 成员对象(逆声明序)→ 基类。

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 → 查表 → 跳转)。代价:

  1. 每个带虚函数的对象多一个指针(8 字节)的大小;
  2. 每次虚调用多一次间接寻址(比直接调用略慢,分支预测也难命中);
  3. 虚函数基本无法内联。

所以”该用多态时用多态,别为没用处的 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 是纯虚函数:只声明接口不提供实现,含它的类就是抽象类,不能实例化、但可以定义指针/引用指向派生类;派生类必须实现全部纯虚函数才能创建对象;抽象类把”子类必须提供什么”变成编译器强制执行的契约,调用方只依赖抽象类即实现解耦;纯虚析构函数必须有实现,否则链接报错。

分享

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

重学cpp(二)
https://fatdog.20060113.xyz/posts/cpp-again2/
作者
神秘大胖狗
发布于
2026-10-22
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

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