本文把 C++ 类相关的核心知识点串成一条线:继承 → 函数重写 → 虚函数 → 多态 → 抽象类,再补上 static、const、this、友元等常用特性。

一、头文件保护:#pragma once

写多文件程序时,同一个头文件可能被重复 #include(比如 A 和 B 都包含了 shape.h),会导致类被重复定义的编译错误。#pragma once 放在头文件最开头,保证它在一个编译单元里只被展开一次:

1
2
3
4
5
6
// shape.h
#pragma once

class Shape {
// ...
};

它与老式的 include guard 等价,但更简洁、不易写错:

1
2
3
4
5
// 老式写法,效果相同
#ifndef SHAPE_H
#define SHAPE_H
class Shape { /* ... */ };
#endif

#pragma once 是编译器扩展(主流编译器都支持),写头文件时优先用它。

二、继承

2.1 继承关系示意

Shape 是基类,Circle 和 Polygon 继承它,Rectangle、Triangle 又继承 Polygon。

2.2 基本语法

1
2
3
class 派生类 : public 基类名称 { /* ... */ };
class 派生类 : protected 基类名称 { /* ... */ };
class 派生类 : private 基类名称 { /* ... */ };

三种继承方式决定基类成员在派生类中的访问级别:

基类中的访问级别 public 继承 protected 继承 private 继承
public public protected private
protected protected protected private
private 不可访问 不可访问 不可访问

注意:class 的默认继承方式是 private,而 struct 的默认是 public。所以写明 public 是好习惯。

2.3 构造函数的初始化列表

构造函数后面加 : 可以初始化成员变量,这叫初始化列表:

1
2
3
4
5
6
class Triangle {
public:
Triangle() : a(0), b(0), c(0) {} // 初始化列表
private:
float a, b, c;
};

初始化列表和「在函数体里赋值」的区别:初始化列表是直接构造,函数体内赋值是先默认构造再赋值。对 const 成员、引用成员、以及没有默认构造函数的对象成员,只能用初始化列表,这是必须掌握的写法。

初始化列表的执行顺序只和成员声明顺序有关,与列表书写顺序无关——写反了容易踩坑,建议按声明顺序写。

三、函数重写:普通函数 vs 虚函数

3.1 普通函数重写:看指针类型(静态绑定)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
using std::cout;

class Shape {
public:
void area() { cout << "调用了基类\n"; }
};

class Circle : public Shape {
public:
void area() { cout << "调用了子类\n"; }
};

int main() {
Circle obj;
Shape* p = &obj; // 基类指针指向子类对象
Shape& ref = obj; // 基类引用绑定子类对象
obj.area(); // 用对象调用 → 子类
p->area(); // 用基类指针 → 基类
ref.area(); // 用基类引用 → 基类
return 0;
}

输出:

1
2
3
调用了子类
调用了基类
调用了基类

关键:没有 virtual 时,调用哪个函数在编译期就按指针/引用的静态类型决定了,p 是 Shape*,所以就调基类版本。

3.2 虚函数:看对象的实际类型(动态绑定)

在基类函数前加 virtual:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
using std::cout;

class Shape {
public:
virtual void area() { cout << "调用了基类\n"; }
};

class Circle : public Shape {
public:
void area() { cout << "调用了子类\n"; }
};

int main() {
Circle obj;
Shape* p = &obj;
Shape& ref = obj;
obj.area();
p->area();
ref.area();
return 0;
}

输出:

1
2
3
调用了子类
调用了子类
调用了子类

加上 virtual 后,p->area() 会在运行时根据 p 实际指向的对象类型(Circle)去调用子类版本。这种「同一份调用代码,因对象不同而产生不同行为」的现象,就是多态。

四、多态

4.1 构成多态的条件

四个条件缺一不可:

  1. 基类中该函数声明为虚函数;
  2. 派生类用 public 继承;
  3. 派生类重写了该虚函数(函数名、参数列表、返回类型都要一致);
  4. 通过基类的指针或引用调用该函数。

理解要点:基类指针/引用可以指向不同的派生类对象,调用同一个虚函数时就会执行各自重写的版本,从而产生不同结果。

4.2 虚析构函数

只要类可能被继承,基类析构函数就应该声明为 virtual,否则用基类指针 delete 派生类对象时,派生类的析构函数不会被调用,造成资源泄漏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
using std::cout;

class Base {
public:
virtual ~Base() { cout << "~Base\n"; } // 虚析构
};

class Derived : public Base {
public:
~Derived() { cout << "~Derived\n"; } // 先执行,再执行基类析构
};

int main() {
Base* p = new Derived();
delete p; // 输出:~Derived 换行 ~Base,两个析构都被正确调用
return 0;
}

去掉 virtual 后只会输出 ~Base,Derived 里的资源就泄漏了。这是面试和实际开发中都非常常见的坑。

五、纯虚函数与抽象类

函数头后面加 = 0 就是纯虚函数,它只有声明没有实现:

1
2
3
4
class Shape {
public:
virtual void draw() = 0; // 纯虚函数
};

包含纯虚函数的类叫抽象类(abstract class),有时也叫接口类:

  • 不能直接实例化抽象类;
  • 必须派生一个类并重写全部纯虚函数,派生类才能被实例化;
  • 如果派生类没有重写全部纯虚函数,它仍然是抽象类,依旧不能实例化。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using std::cout;

class Shape {
public:
virtual double area() const = 0;
virtual ~Shape() = default;
};

class Circle : public Shape {
private:
double r;
public:
Circle(double r) : r(r) {}
double area() const override { return 3.14159 * r * r; }
};

int main() {
// Shape s; // 错误:抽象类不能实例化
Circle c(2.0);
cout << c.area(); // OK
return 0;
}

抽象类的价值在于定义接口规范:调用方只依赖 Shape*,不关心具体是圆还是矩形。

六、override 与 final 限定符

这两个是 C++11 引入的,能显著减少多态相关的手误。

6.1 override:让编译器检查是否真的重写

下面这段代码有个隐蔽的 bug——派生类把参数从按值传递改成了按引用传递,于是它不再构成重写,而是变成了一个全新的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
using std::cout;
using std::endl;

class Shape {
public:
virtual void dosth(int val) { cout << "调用了基类: " << val << endl; }
};

class Circle : public Shape {
public:
void dosth(int& val) { cout << "调用了子类: " << val << endl; } // 参数类型不同,不是重写!
};

int main() {
Circle obj;
Shape* p = &obj;
p->dosth(100); // 输出:调用了基类: 100
return 0;
}

代码能编译、能运行,但结果和预期完全不同——这种「语法没错、逻辑错了」的 bug 最难排查。

在函数后面加 override,编译器会主动检查这个函数是否真的重写了基类的虚函数,参数类型不一致就直接报错:

1
2
3
4
class Circle : public Shape {
public:
void dosth(int& val) override { cout << "调用了子类: " << val << endl; }
};

编译报错(GCC 风格):

1
error: 'void Circle::dosth(int&)' marked 'override', but does not override

建议:重写虚函数时一律加上 override,让编译器帮你把关(现代 IDE 也能实时标红提示,不能只依赖它)。

6.2 final:禁止继续重写

final 可以放在两处:

放在函数末尾 —— 禁止子类重写这个虚函数:

1
2
3
4
class Shape {
public:
virtual void dosth(int val) final { /* 子类不能再重写 dosth */ }
};

放在类名后面 —— 禁止这个类被继承:

1
2
3
4
5
6
class Shape final {
public:
virtual void dosth(int val) { /* ... */ }
};

// class Circle : public Shape { }; // 编译错误:不能继承 final 类

七、静态成员

7.1 静态成员变量

静态成员变量不属于任何对象,整个类只有一份,相当于「被限定在类作用域内的全局变量」,用 类名:: 访问:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
using std::cout;
using std::endl;

class Demo {
private:
static int a; // 私有静态成员
public:
static int b;
static int getA() { return a; } // 静态成员函数可以访问私有静态成员
};

int Demo::a = 1; // 静态成员必须在类外定义并初始化
int Demo::b = 2;

int main() {
cout << Demo::getA() << endl; // 1
cout << Demo::b << endl; // 2
return 0;
}

要点:

  • 类内只是声明,必须在类外定义(分配存储),否则链接报错;
  • 访问方式:类名::成员,不需要创建对象;
  • 所有对象共享同一份数据,一个对象改了,其他对象看到的也变了。

7.2 静态成员函数

静态成员函数没有 this 指针,因此只能访问静态成员,不能直接访问非静态成员:

1
2
3
4
5
6
7
8
9
10
11
class Demo {
private:
static int a;
int b;
public:
Demo() : b(2) {}
static int getNum() {
return a + b; // 编译错误:静态函数里不能使用非静态成员 b
}
};
int Demo::a = 1;

报错信息:

1
error: invalid use of member 'Demo::b' in static member function

原因:非静态成员 b 依附于具体的对象而存在,而静态函数不绑定任何对象,编译期无法确定该用哪个对象的 b。

解决办法:把对象作为参数传进去,通过对象访问其成员(无论公有私有都行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Demo {
private:
static int a;
int b;
public:
Demo() : b(2) {}
static int getNum(Demo& obj) {
return obj.a + obj.b; // 通过具体对象访问,合法
}
};
int Demo::a = 1;

// 调用
Demo obj;
std::cout << Demo::getNum(obj); // 3

八、单例模式(Singleton)

单例保证一个类在整个程序中只有一个实例,并提供全局访问点。实现要点是「把构造函数藏起来」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Singleton {
private:
Singleton() {} // 构造函数私有,外部无法 new
Singleton(const Singleton&) = delete; // 禁止拷贝构造
Singleton& operator=(const Singleton&) = delete; // 禁止拷贝赋值
public:
static Singleton& getInstance() {
static Singleton instance; // C++11 起,局部静态变量的初始化是线程安全的
return instance;
}
void doSomething() { /* ... */ }
};

// 使用
Singleton::getInstance().doSomething();

这是最常用的 Meyers Singleton 写法:靠「私有构造 + 删除拷贝 + 静态局部变量」三件套,既禁止了外部创建,又保证了唯一性,还自带线程安全。

九、const 成员函数

在函数声明和定义的末尾加 const,表示这个函数不会修改对象的成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
using std::cout;

class Demo {
private:
int a;
public:
Demo() : a(0) {}
int get() const { return a; } // const 成员函数
void set(int v) { a = v; } // 非 const 成员函数
};

int main() {
Demo obj;
obj.set(10); // OK
cout << obj.get(); // OK

const Demo c; // const 对象
cout << c.get(); // OK:const 对象只能调用 const 成员函数
// c.set(1); // 错误:const 对象不能调用非 const 成员函数
return 0;
}

三条规则:

  • const 成员函数内不能修改任何成员变量;
  • const 成员函数内不能调用非 const 成员函数(因为后者可能改成员);
  • const 对象只能调用 const 成员函数。

实践建议:所有「只读」的 getter 都加上 const,否则 const 对象和 const 引用就没法用它们。

十、this 指针

this 是成员函数隐含的第一个参数,指向调用该函数的对象本身:

  • this->member:把 this 当指针用;
  • (*this).member:先解引用得到对象,再用 . 访问成员,等价于 this->member(* 是解引用运算符)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
using std::cout;

class Demo {
private:
int a, b;
public:
Demo(int a, int b) {
this->a = a; // 参数名与成员名相同时,用 this 区分
(*this).b = b;
}
void print() { cout << a << " " << b; }
};

int main() {
Demo obj(1, 2);
obj.print(); // 1 2
return 0;
}

最常见的用途就是解决形参名与成员变量同名的问题。

10.1 链式调用

要实现 obj.add(1).add(2).add(3) 这种链式调用,函数必须返回当前对象的引用 类名&:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
using std::cout;

class Demo {
private:
int a;
public:
Demo(int a) { this->a = a; }
Demo& add(int num) { // 返回引用,才能继续链下去
a += num;
return *this; // 返回对象自身
}
void print() { cout << a; }
};

int main() {
Demo obj(1);
obj.add(1).add(1);
obj.print(); // 3
return 0;
}

易错写法:返回值写成 Demo(不是 Demo&)——

1
2
3
4
Demo add(int num) {
a += num;
return *this; // 返回的是【拷贝出来的临时对象】
}

后果:obj.add(1).add(1) 中,第一次 add 修改的是 obj,但返回的是 obj 的副本,第二次 add 改的是那个副本,obj 本身不再变化。链多少层都没用。

这个 bug 的三个特点:

  1. 语法完全正确,不报编译错误——属于逻辑 bug,最难排查;
  2. 值返回会触发拷贝构造函数,产生额外的临时对象(有性能开销);
  3. 想连续修改同一个对象,必须返回引用 Demo&。

十一、友元 friend

友元允许类外部的函数或其他类访问这个类的 private / protected 成员,相当于定向「开一个后门」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>

class A {
private:
int n;
public:
A() : n(0) {}
friend class B; // 友元类:B 可以访问 A 的私有成员
friend void showA(const A& a); // 友元函数:这个普通函数也能访问
};

class B {
public:
void add(A& obj) { obj.n++; } // 合法:B 是 A 的友元
};

void showA(const A& a) {
std::cout << a.n << std::endl; // 合法:友元函数
}

int main() {
return 0;
}

需要注意:

  • 友元关系单向:A 声明 B 是友元,只意味着 B 能访问 A,反过来不行;
  • 友元关系不能被继承,也不具有传递性(B 是 A 的友元、C 是 B 的友元,不代表 C 能访问 A);
  • 友元破坏了封装,应谨慎使用。常见合理场景是运算符重载(如 operator<<)和需要紧密协作的两个类。

十二、小结

  • 头文件用 #pragma once 防止重复包含;
  • class 默认 private 继承,写 public 更明确;成员初始化优先用初始化列表;
  • 普通函数按静态类型调用;加 virtual 后按实际对象类型调用,这就是多态;
  • 多态四条件:虚函数、public 继承、重写、基类指针/引用;
  • 基类析构函数应声明为 virtual,否则 delete 派生类对象会漏掉派生类析构;
  • 纯虚函数 = 0 构成抽象类,不能实例化,只能被继承;
  • 重写虚函数加 override 让编译器检查,final 禁止重写或继承;
  • 静态成员属于类而非对象,类外定义、类名:: 访问;静态函数只能直接访问静态成员;
  • const 成员函数不修改成员,const 对象只能调 const 函数;
  • 链式调用必须返回 引用(类名&),返回值会让后续修改丢失;
  • 友元是单向、不可继承的「定向开权限」,慎用。