10546 字
53 分钟
面向对象考前速通笔记 (3) 继承、模板

一. 单继承#

WARNING

**单继承(single inheritance)**是指一个派生类只有一个直接基类。

C++ 允许一个类(派生类)继承另一个类(基类)的功能,从而实现代码复用。

class 派生类名 : <继承方式> 基类名 {
// ... 派生类新增的成员
};

<继承方式> 可以是 publicprivateprotected(也可以省略,就是默认private)。这个关键字非常重要,它决定了基类成员在派生类中的访问权限。如果省略,默认为 private

派生类会自动获得基类的所有成员(成员变量和成员函数),但不包括基类的构造函数、析构函数和赋值运算符重载。

如果派生类定义了和基类同名的成员,也就是重定义。不会报错,之前那个就没有用了,如果要访问之前那个,要基类::成员名来访问。如果成员是函数,也是类似的,即使参数不同,还是要指定基类才能访问。

(这不是函数名重载,因为不属于同一个作用域)

class A // 基类
{
int x, y;
public:
void f();
void g();
};
class B: public A // 派生类
{
int z; // 新定义的数据成员
public:
void h(); // 新定义的成员函数
};

上面的B,也包含A中的x,y,f,g

可以把派生类的对象理解成它包含了基类的一个子对象,该子对象的内存空间位于派生类对象内存空间的前部。

如果没有在派生类中显式说明,则基类的友元不是派生类的友元;如果基类是另一个类的友元,而在该类中没有显式说明,则派生类也不是该类的友元。

到这里就该疑问了,B有是有了A的一切成员,那这些继承的成员的权限呢?会变化吗?

会变! 成员被继承后的最终权限,由它在基类中的原始权限继承方式两者共同决定

1. 继承方式#

可以把“继承方式”想象成一个权限过滤器或一个总开关。它限制了基类成员在派生类中最高能拥有什么权限

  • public 继承:最开放的继承方式。它告诉编译器:“基类的成员权限基本保持不变。”(具体规则见下表)
  • protected 继承:一个更严格的开关。它告诉编译器:“基类中所有 public 的成员,到了我这里(派生类)最高也只能是 protected。”
  • private 继承:最严格的继承方式。它告诉编译器:“基类中所有 publicprotected 的成员,到了我这里全部都变成 private。”

我们可以用一个表格清晰地展示这个规则:

基类中的原始权限public 继承protected 继承private 继承
publicpublicprotectedprivate
protectedprotectedprotectedprivate
private不可访问不可访问不可访问

特别注意:无论采用哪种继承方式,基类的 private 成员永远只能被基类自己访问,派生类永远无法直接访问。虽然派生类的对象里确实包含了这个成员(占用了内存空间),但在语法上就是不能碰。

继承方式的调整#

但是继承方式是可以调整的。

这是一个相对不常用但需要了解的技巧,主要用在 privateprotected 继承中。我们之前学到,用 private 方式继承时,基类所有的 publicprotected 成员在派生类中都会变成 private。但如果我就是想让其中一两个成员重新变回 publicprotected,怎么办?

解决方法:在派生类对应的权限区(publicprotected)中,使用 基类名::成员名; 的语法,可以“恢复”该成员的访问权限。

class A {
public:
void f1(), f2(), f3();
protected:
void g1(), g2(), g3();
};
class B : private A { // 采用 private 继承,A的所有成员在B中默认都是private
public:
A::f1; // 把原本应为 private 的 f1,权限调整回 public
A::g1; // 把原本应为 private 的 g1,权限调整为 public
protected:
A::f2; // 把原本应为 private 的 f2,权限调整为 protected
A.g2; // 把原本应为 private 的 g2,权限调整为 protected
// f3 和 g3 没有被提及,所以它们在 B 中仍然是 private
};

重要限制:你不能通过这种方式让成员的权限变得比它在基类中更宽松。比如,你不能把基类中的 protected 成员 g1 在派生类里调整为 public

子类型关系#

public 继承方式有着特殊的意义,它将子类型(subtype)用作基类的对外接口”。

意思就是:如果 class B 公开继承(public)了 class A,那么就意味着 B 就是一种 A。你可以把派生类当作基类来用,但反过来不行。

什么意思呢?举个例子,class B : public A,所以一个 B 对象就是一个 A 对象(并且还带有额外功能)。

合法的操作(把派生类当基类用 - Upcasting)

这就像说“一只哈士奇(派生类)可以被当作一只狗(基类)来看待”。

  1. A *p = &b;
    • 含义:基类指针可以指向派生类对象。
    • 解释:你完全可以用一个“狗”的指针去指向一只“哈士奇”,这是天经地义的。
  2. a = b;
    • 含义:可以将派生类对象赋值给基类对象。
    • 解释:这个操作叫做对象切片 (Slicing)。它会把 b 对象中从 A 继承来的那一部分“切”下来,拷贝给 ab 自己独有的成员(如 int z;)会被忽略掉。就像你把一只“哈士奇”的信息登记到“狗”的档案里,你只会记录犬类共有的信息(姓名、年龄),而不会记录哈士奇独有的“拆家指数”。
  3. func1(&b); func2(b); func3(b);
    • 含义:如果一个函数需要一个基类对象(或其指针/引用),你可以把一个派生类对象传给它。
    • 解释:如果一个函数需要“一只狗”,你把“一只哈士奇”传进去完全没问题。

非法的操作(把基类当派生类用 - Downcasting)

这就像说“你不能把一只普通的狗(基类)当作一只哈士奇(派生类)”,因为它不具备哈士奇的全部特征。

  1. B *q = &a;
    • 含义:派生类指针不能指向基类对象。
    • 解释:你不能用“哈士奇”的指针去指向一只“普通的狗”,因为这只狗不一定有哈士奇的全部属性(比如它没有成员 z)。如果你强行这么做,然后通过指针 q 去访问 z (q->z),程序就会出错。
  2. b = a;
    • 含义:不能将基类对象赋值给派生类对象。
    • 解释:你不能把“狗”档案里的信息直接填到“哈士奇”的档案里,因为“哈士奇”档案里“拆家指数”那一栏(成员 z)该填什么呢?基类对象 a 根本没有这个数据。
  3. func4(&a); func5(a); func6(a);
    • 含义:如果一个函数需要一个派生类对象,你不能把一个基类对象传给它。
    • 解释:如果一个函数明确需要“一只哈士奇”,你给它一只“普通的狗”是无法完成任务的。

2. 派生类对象的初始化和消亡#

构造函数#

派生类对象的初始化,是由基类和派生类共同完成的。

  1. 基类的数据成员,由基类的构造函数初始化。默认情况下调用基类的默认构造函数。如果要调用基类的非默认函数,就必须在派生类的成员初始化表中指出。

    class A
    {
    int x;
    public:
    A() { x = 0; }
    A(int i) { x = i; }
    };
    class B: public A
    {
    int y;
    public:
    B() // 将调用 A 的默认构造函数 A()
    { y = 0; }
    B(int i) // 将调用 A 的默认构造函数 A()
    { y = i; }
    B(int i, int j):A(i) // 将调用 A 的构造函数 A(int i)
    { y = j; }
    };
    // ...
    B b1; // 调用 A::A() 和 B::B(),b1.x 等于 0,b1.y 等于 0
    B b2(1); // 调用 A::A(int) 和 B::B(int),b2.x 等于 0,b2.y 等于 1
    B b3(1,2); // 调用 A::A(int) 和 B::B(int,int),b3.x 等于 1,b3.y 等于 2
  2. 派生类的数据成员,由派生类的构造函数初始化。

拷贝构造函数#

如果派生类没写任何拷贝构造函数。

// 你没有为 B 写拷贝构造函数
B b1( ... );
B b2 = b1; // 编译器自动工作

编译器知道你想干什么。你想完整地复制一个 b1b2。一个完整的 B 对象包含两部分:

  1. 从基类 A 继承来的部分。
  2. B 自己独有的部分。

于是,智能的编译器会自动这么做:

  • 对于基类 A 的部分:调用 A拷贝构造函数 A(const A&) 来完成拷贝。
  • 对于派生类 B 的部分:逐个拷贝 B 的成员变量。

如果自己实现了拷贝构造函数。问题来了,一切都要我们自己负责。

“在任何派生类的构造函数中,如果你没有在成员初始化列表里明确地告诉我要如何初始化基类 A,那我就只能调用 A 的默认(无参)构造函数 A()。” 这个规则是铁律,它不管你当前写的是不是“拷贝构造函数”。只要你写了构造函数,就得遵守这个规则。

class B : public A {
public:
// ...
// 你写的“手动挡”拷贝构造函数
B(const B& other) {
// 函数体内部
this->y = other.y; // 你只告诉了编译器如何拷贝 B 自己的成员
}
private:
int y;
};

这里你没有在成员初始化列表里给编译器任何关于如何处理基类 A 的指令。比如,你没有写 : A(other)。编译器遵守铁律,自动在背后调用了 A默认构造函数 A()。然后,才进入你的函数体,执行 this->y = other.y;

这几乎肯定是一个BUG。A的值包没有赋值进去的。

正确的写法:

// 正确的、手动的拷贝构造函数
B(const B& other)
: A(other) // <-- 在初始化列表里,明确调用基类的拷贝构造函数!
{
this->b_val = other.b_val;
std::cout << "B's CORRECT copy constructor called." << std::endl;
}

此时,A(other) 这里的 other (一个 B 对象) 会被自动“向上转型”为一个 A 对象,正好匹配 A 的拷贝构造函数 A(const A&)

WARNING

铁律:自定义派生类拷贝构造函数时,永远不要忘记在成员初始化列表中调用基类的拷贝构造函数:Derived(const Derived& d) : Base(d) { ... }

赋值运算符#

赋值运算符 operator= 的行为与构造函数不同。

  • 隐式行为:如果你不写派生类的 operator=,编译器生成的版本会自动调用基类的 operator=
  • 显式行为:如果你重载了派生类的 operator=,编译器不会自动调用基类的版本,你必须手动调用

如何手动调用? 不能用初始化列表(它只用于构造函数)。你必须在函数体内明确调用。

class B : public A {
public:
B& operator=(const B& other) {
if (this == &other) { // 1. 防止自我赋值
return *this;
}
// 2. 手动调用基类的赋值运算符,把基类部分搞定
// A::operator=(other); 两种写法都行
*(A*)this = other;
// 3. 赋值派生类自己独有的成员
// ...
return *this; // 4. 返回*this以支持链式赋值
}
};

二. 成员函数调用的动态绑定#

1. 静态绑定->虚函数#

当一个基类(父类)的指针或引用,指向了一个派生类(子类)的对象时,如果基类和派生类里都有一个同名函数(比如 f()),那么通过这个指针调用函数,到底会执行谁的版本?

// 基类指针 p,但它实际指向了一个派生类 B 的对象地址
B b;
A *p = &b;
p->f(); // ??? 这里调用的到底是 A::f() 还是 B::f()?

C++ 默认采用的是静态绑定

  • 静态绑定是什么?编译的时候,编译器就决定了要调用哪个函数。它做决定的依据是指针或引用自身的类型(静态类型),而不是它实际指向的对象的类型(动态类型)。
  • 对应到例子里: 指针 p 的类型被声明为 A*。所以编译器看到 p->f(),就会说:“pA 类型的指针,那就调用 A 类的 f() 函数吧。” 它根本不关心 p 在程序运行时到底指向了谁。
  • 结论 (第2张图的重点): 默认情况下,p->f()x.f() 调用的都是 A::f()。这通常不是我们想要的结果。我们明明指向了一个 B 对象,却没能调用 B 的行为,这就失去了灵活性。

为了解决上面的问题,实现我们想要的“调用谁就执行谁的动作”这种智能行为,C++ 提供了虚函数机制。

基类中,使用 virtual 关键字声明函数,就能启用动态绑定 (Dynamic Binding)

动态绑定是什么? 与静态绑定相反,动态绑定是在程序运行的时候,才根据指针或引用**实际指向的对象类型(动态类型)**来决定调用哪个函数。

我们只需要在基类 Af() 前面加上 virtual 关键字。

class A {
public:
// 加上 virtual,告诉编译器这个函数要用“动态绑定”
virtual void f();
};
class B : public A {
public:
// 基类是 virtual 后,派生类中同名的函数自动也是 virtual
void f();
};
B b;
A *p = &b;
p->f(); // 现在,程序运行时会检查 p 指向的是 B 对象,于是调用 B::f()!

virtual 关键字就像一个开关,它把函数调用的决策时机从编译时推迟到了运行时,从而实现了多态。

如果要强制使用Avirtual f,也很简单:p->A::f()就好了。

不安全的析构问题#

正常是这样写代码的:

B* ptrB = new B();
delete ptrB;

在这种情况下,无论 ~A() 是不是虚函数,析构顺序永远是正确的 ~B() -> ~A()

但是有的时候,我们会骗一下编译器:

A* ptrA = new B(); // 指针是 A 类型,对象是 B 类型
delete ptrA;

现在,当编译器看到 delete ptrA; 时,它遇到了一个决策难题。它只从表面上看到了 ptrA 是一个 A* 类型的指针。

如果~A不是虚函数。编译器会采用静态绑定

它的“思考”过程是:“我手里的指针是 A* 类型。析构函数 ~A() 不是 virtual 的,这表示我不需要去探究这个指针背后到底藏着什么。我的任务就是调用 A* 类型对应的析构函数。”

执行结果:程序只调用了 A 的析构函数 ~A()。它根本不知道这个指针实际指向的是一个 B 对象,因此 B 的析构函数 ~B() 被完全跳过了。产生内存泄漏!

如果~A是虚函数,这时候才会正确地调用出~B,然后向上调用~A

通过基类调用派生类独有函数#

如果派生类 B 有一个基类 A 完全没有的函数,比如 void g(),我们能通过基类指针 p 调用它吗?

class A { public: virtual void f(); };
class B : public A { public: void g(); };
A* p = new B;
p->g(); // 编译错误!

为什么会编译错误? 因为编译器进行静态类型检查时,发现 pA* 类型,而 class A 的定义里根本没有 g() 这个函数。编译器直接就判错了,它根本不会去想 p 将来可能会指向 B

要想解决这个问题,我们必须把 A* 类型的指针转换B* 类型的指针。书上介绍了两种方法:

1)C风格的强制转换:

((B*)p)->g(); // 告诉编译器:“别管了,就当它是 B* 类型处理”

这样做非常危险。如果 p 恰好指向的确实是 B 对象,代码能正常工作。但如果 p 指向的是一个 A 对象呢?A 对象内存里根本没有 g() 函数,强制执行就会导致程序崩溃或出现无法预料的错误。这等于是在欺骗编译器。

2)安全的C++动态类型转换 dynamic_cast

B* q = dynamic_cast<B*>(p); // 尝试将 p 转换为 B*
if (q != nullptr) { // 如果转换成功...
q->g(); // ...再安全地调用 g()
}

dynamic_cast 会在程序运行时检查 p 是否真的指向一个 B 类型的对象(或者是 B 的某个派生类)。

  • 如果转换成功,它会返回一个有效的 B* 指针。
  • 如果转换失败(比如 p 实际指向的是一个 A 对象),它会返回 nullptr (空指针)。

2. 纯虚函数、抽象类#

纯虚函数 (Pure Virtual Function)

  • 定义:它是一个只有声明、没有实现的虚函数。你通过在函数声明的末尾写上 = 0; 来表明它是一个纯虚函数。
  • 语法virtual <返回类型> <函数名>(<参数列表>) = 0;
  • 含义: 它就像一个强制性的合同。基类在这里规定:“任何继承我的非抽象子类,都必须自己去实现这个函数。我自己不提供默认的实现,因为对我这个笼统的基类来说,谈论具体实现没有意义。”
class A {
public:
virtual int f() = 0;
// ...
}

抽象类 (Abstract Class)

  • 定义:任何包含至少一个纯虚函数的类,自动成为抽象类。
  • 最重要的规则:抽象类不能被实例化。也就是说,你不能创建抽象类的对象。
  • 作用:抽象类的存在不是为了创建它自己的对象,而是为了给它的所有派生类提供一个统一的接口(interface)*和*框架(framework)。它定义了一系列所有“子孙后代”都必须遵守的规则。

例如:

class Figure { // 抽象基类
public:
virtual void draw() const = 0; // 纯虚函数:每个图形都必须能被绘制
virtual void input_data() = 0; // 纯虚函数:每个图形都必须能输入自己的数据
};

课本上283页的代码中,Rectangle, Circle, Line 这三个类都继承自 Figure。它们通过提供 draw()input_data() 函数的具体实现,履行了与基类签下的“合同”。

  • Rectangle::draw() 会画一个矩形。
  • Circle::draw() 会画一个圆形。
  • Line::input_data() 会提示用户输入两个端点的坐标。

现在,这三个类都是具体类 (Concrete Class),它们可以被实例化(创建对象)。

FiguresMgr 管理器#

FiguresMgr 类里有一个非常关键的成员:

Figure *figures[MAX_NUM_OF_FIGURES];

这是一个基类指针数组。由于 Rectangle, Circle, Line 对象都是 Figure 的一种,所以这个数组可以存放指向这三种不同类型对象的指针。它并不知道,也不关心每个位置上存的到底是什么具体形状。

三. 多继承#

如果我们想创建一个新类,它需要融合两个已有的、互不相关的类的功能,该怎么办?

我们已经有两个设计好的类,AB

A 类有自己的成员:int m;void fa(); B 类也有自己的成员:int n;void fb();

class A {
public:
int m;
void fa() { /* ... */ };
};
class B {
public:
int n;
void fb() { /* ... */ };
};

现在,我们需要创建一个新类 C,它要同时拥有 A 和 B 的所有成员,并且还要有自己独有的成员 int r;void fc();

如果我们只会单继承,可能会想出两种笨办法:

方法一:让 C 继承 A,然后手动把 B 的代码抄一遍过来。

class C : public A { // C 继承 A
int n; // 从 B 手动复制过来
public:
void fb() { /* ... */ }; // 从 B 手动复制过来
int r;
void fc() { /* ... */ };
};

方法二:让 C 继承 B,然后手动把 A 的代码抄一遍过来。

缺点:

  1. 概念上的混乱 (Conceptual Confusion)A 类的 mB 类的 n 本来是各自独立的属性,分属不同的类。现在你把它们强行塞进同一个 C 类里,破坏了类的独立性和封装性,逻辑上不清晰。
  2. 代码维护的噩梦 (Maintenance Nightmare):这是最严重的问题。想象一下,一年后,你的同事在原始的 A 类中修改了 fa() 函数,修复了一个重要的 bug。但是,他根本不知道你曾经手动把 fa() 的代码复制到了 C 类里。结果就是,A 类的 bug 修复了,但你的 C 类里依然是那个陈旧的、有 bug 的版本! 这会导致代码不一致,产生非常隐蔽的错误。
  3. 类型系统的限制 (Type System Limitation):如果 C 继承自 A,那么在程序看来,一个 C 对象“是”一个 A 对象 (C is an A),但它“不是”一个 B 对象。这意味着,如果有一个函数需要一个 B类型的参数,你无法把 C 对象传递给它。你的 C 只能顶替 A 或者 B 中的一个,无法同时拥有两种身份。

1. 多继承派生类#

// C 同时继承 A 和 B
class C : public A, public B {
public:
int r;
void fc() { /* ... */ };
};

语法非常直观:在类名后面用逗号隔开所有要继承的父类。这一行代码就完美地解决了第一页提出的所有问题。一个 C 类的对象,现在既是 A 类型,也是 B 类型。

内存布局:一个 C 类的对象在内存中,可以看成是一个 A 子对象、一个 B 子对象和 C 自己成员的连续排列。 这解释了为什么 C 的对象能调用 AB 的方法——因为它体内完整地包含了 AB 的结构。

构造函数的执行顺序:创建 c 对象时,构造函数的调用顺序是按照继承时声明的顺序:先调用 A 的构造函数,再调用 B 的构造函数,最后才调用 C 自己的构造函数。销毁时顺序相反。这很符合逻辑:必须先建好地基(父类),才能盖大楼(子类)。

成员访问:对 c 对象来说,访问 fa()fb()fc() 都没问题,因为它们都是 C 的一部分。

C c;
A* pa = &c; // pa 指向 c 对象中的 A 部分
B* pb = &c; // pb 指向 c 对象中的 B 部分

&c 是整个 C 对象的起始地址。

A* pa = &c; 时,pa 指针得到的地址,是 c 对象内部那个 “A 子对象” 的起始地址。

B* pb = &c; 时,pb 指针得到的地址,是 c 对象内部那个 “B 子对象” 的起始地址。

papb 的地址值通常是不同的! 因为在内存布局中,B 子对象跟在 A 子对象的后面。编译器会自动完成这个地址的调整。

2. 名冲突#

如果 AB 中有一个同名的函数,比如都叫 print(),那么 c.print() 该调用谁的?

class A {
public:
void f(); // A 有一个 f()
void g();
};
class B {
public:
void f(); // B 也有一个 f()
void h();
};
class C : public A, public B {
public:
void func() {
f(); // 错误!有歧义!
}
};
// ...
C c;
c.f(); // 同样错误!有歧义!

当你试图在 C 中调用 f() 时,编译器就懵了:“你到底想调用从 A 继承来的 f(),还是从 B 继承来的 f()?” 这种无法做出决定的情况,就叫做二义性 (ambiguity)名冲突

解决办法非常直接:明确地告诉编译器你要用谁的。我们使用作用域解析运算符 :: 来“指名道姓”。

class C : public A, public B {
public:
void func() {
A::f(); // OK,明确调用 A 的 f()
B::f(); // OK,明确调用 B 的 f()
}
};
// ...
C c;
c.A::f(); // OK,在外部调用 A 的 f()
c.B::f(); // OK,在外部调用 B 的 f()

3. 重复继承问题#

如果 AB 本身都继承自同一个基类 D,那么 C 继承 AB 后,体内是不是就有了两份来自 D 的成员?这要如何解决?

这是多继承里最著名的问题,也叫菱形继承问题 (Diamond Problem)

当多个父类(比如 BC)继承自同一个基类(比如 A),然后又有一个子类(比如 D)同时继承了这两个父类(BC)时,就会形成一个菱形的继承结构。

A
/ \
B C
\ /
D

A 类里有一个成员 int x;

  • B 继承了 A,所以 B 有一份 x
  • C 也继承了 A,所以 C 也有一份 x
  • 现在 D 同时继承了 BC

一个 D 类的对象里,会包含两份 A 的成员! 一份是通过 B 继承来的 (B::x),另一份是通过 C 继承来的 (C::x)。

这通常不是我们想要的。它不仅浪费内存,而且同样会造成二义性:当你在 D 的对象 d上访问 x 时 (d.x),编译器又会懵掉:“你要访问的是 B 那条路上的 x,还是 C 那条路上的 x?”

为了解决这个问题,C++ 引入了虚基类的概念。我们需要在“菱形”中间的那一层,也就是 BC 继承 A 的时候,使用 virtual 关键字。

// A 是顶端基类
class A { public: int x; };
// B 和 C 在继承 A 时,使用 virtual 关键字
class B : virtual public A { /* ... */ };
class C : virtual public A { /* ... */ };
// D 的继承方式不变
class D : public B, public C {
public:
void f() {
x = 10; // OK!现在不再有歧义了
}
};

virtual 关键字的作用: 它告诉编译器:“请确保 A 这个基类在后续的任何多继承中,只保留一份实例。” 这样做了之后,D 类的对象在内存中就只有一个共享的 A 子对象,因此也只有一份成员 x。所有对 x 的访问都指向这个唯一的实例,二义性就消除了。

四. 模板#

我们能不能针对不同类型的数据实体,写出通用的工具函数,比如排序?更具体一些,能不能写一个排序函数,函数中仅仅实现算法,然后能同时支持intdouble和自定义的对象?

可以用函数重载,但这并不是解决这个问题的最佳方案,而且它违背了“代码复用”的初衷。我们对外,尽管能都使用sort函数,但是对内,我们还是要同时写出三个版本的sort,并且算法逻辑都要重新实现一遍:

void sort(int elements[], unsigned int num) {
cout << "调用了 int 版本的排序函数" << endl;
for (int i = 0; i < num; ++i) {
for (int j = i + 1; j < num; ++j) {
// 这是排序的核心逻辑
if (elements[i] > elements[j]) {
int temp = elements[i];
elements[i] = elements[j];
elements[j] = temp;
}
}
}
}
// 重载版本 2: 用于浮点数排序
void sort(double elements[], unsigned int num) {
cout << "调用了 double 版本的排序函数" << endl;
for (int i = 0; i < num; ++i) {
for (int j = i + 1; j < num; ++j) {
// 注意!这里的排序逻辑和上面完全一样!
if (elements[i] > elements[j]) {
double temp = elements[i];
elements[i] = elements[j];
elements[j] = temp;
}
}
}
}

代码很冗余了。而且扩展性很差。

函数重载的本质是为你提供一个统一的函数名,但允许它们有不同的参数列表和不同的实现逻辑。 而在这里,我们的实现逻辑是相同的,只是作用的数据类型不同。

而且函数能重载,类可不能重载。类似的问题,我们能不能实现一个通用的、所有类型的都能使用的栈或者队列?

WARNING

程序设计中,一个程序实体能对多种类型的数据进行操作或描述的特性称为类属性(generics),具有类属性的程序实体通常有类属函数和类属类。类属函数是指一个函数能对不同类型的数据(参数)完成相同的操作;类属类是指一个类的成员类型可变但操作不变。

基于具有类属性的程序实体进行程序设计的技术称为泛型程序设计(generic programming),它为软件复用提供了另一条途径。

在C++中,泛型用模板实现。

1. 模板#

函数模板#

在C++出现之前,C语言的程序员们用一种巧妙但复杂的方法来解决这个问题:void\* 通用指针。

void* 是一种“无类型”的指针,它可以指向任何类型的数据。思路如下:

  1. 写一个通用的排序函数,它的参数不是 int*double*,而是 void*,这样它就能接收任何类型的数组地址。
  2. 因为不知道数组元素的具体类型,所以也不知道每个元素占多大内存(sizeof),也没法直接比较两个元素的大小(比如 a > b)。
  3. 怎么办呢?把这两个信息也作为参数传给函数!
    • 传入每个元素的大小 element_size
    • 传入一个比较函数的地址(函数指针 element_cmp),这个函数专门负责比较两个元素的大小。

所以,C语言版本的通用排序函数看起来非常复杂:

// C风格的通用排序
void sort(void *base, unsigned int num, unsigned int element_size,
bool (*element_cmp)(const void *, const void *));

缺点:

  • 非常复杂:需要手动计算内存地址,进行强制类型转换,容易出错。
  • 不安全:编译器无法进行类型检查,比如你把一个比较整数的函数用在了浮点数数组上,编译时不会报错,但运行时结果就是错的。
  • 调用麻烦:每次调用时,都需要提供一大堆参数,包括大小和比较函数。

C++ 提供了一种更优雅、更安全的解决方案:模板 (Template)

你可以把模板看成是一个**“代码生成器”**。你写一个通用的函数模板,告诉编译器:“这是一个模板,请根据我调用它时使用的具体类型,自动帮我生成对应版本的函数。”

template <class T> // 声明这是一个模板,T 是一个通用的类型参数
void sort(T elements[], unsigned int num) {
// ... 函数体内部可以直接使用 T 类型的变量 ...
// 比如直接用 < 或 > 比较 elements[i] 和 elements[j]
if (elements[i] < elements[j]) {
// ...
}
}

调用:

int a[100];
double b[200];
sort(a, 100); // 1. 编译器看到你传入了 int 类型的数组 a
// 2. 它会自动用 int 替换模板里所有的 T
// 3. 然后生成一个专门给 int 用的 sort 函数,就像这样:
// void sort(int elements[], unsigned int num) { ... }
sort(b, 200); // 1. 编译器看到你传入了 double 类型的数组 b
// 2. 它会自动用 double 替换模板里所有的 T
// 3. 然后生成一个 double 版本的 sort 函数。

这个过程叫做模板实例化 (Instantiation)类型推导 (Type Deduction)。编译器非常聪明,它会根据你传入的实参类型,自动推导出 T 应该是什么,然后帮你生成代码。

优点:

  • 简洁:代码量大大减少,逻辑清晰。
  • 安全:类型检查在编译时就完成了。如果你的类型不支持 < 比较,编译器会直接报错,而不是等到运行时才出问题。
  • 高效:它没有运行时的额外开销,因为最终执行的代码和手写的特定类型版本是一样的。

如果编译器无法判断是什么类型,比如:

template <class T>
T max(T a, T b) {
retrun a > b ? a : b ;
}
int x,y;
double m, n;
... max(x, m); // ? 类型是什么?

就需要显式类型转换,或者显式实例化

... max((double)x, m); // 显式类型转换
... max<double> (x, m); // 显式实例化

也可以传入非类型参数。

类型参数 (Type Parameter):像 class T,它是一个类型的占位符。在实例化时,它会被换成一个具体的类型,比如 intdoublestring

非类型参数 (Non-Type Parameter):像 int size,它是一个常量值的占位符。在实例化时,它会被换成一个具体的、在编译时就能确定的常量值,比如 10256

template <class T, int size> // T 是类型参数,int size 是非类型参数
void f(T a)
{
T temp[size]; // 关键在这里!
// ...
}

在C++中,当你定义一个像这样的静态数组时,数组的大小(size必须是一个在编译时就能确定的常量。你不能使用一个普通的变量来定义它的大小。

模板的作用是在编译期间生成代码。当编译器准备为 f 函数生成一个具体版本的代码时,它需要明确知道 size 到底是多少,否则 T temp[size]; 这一行就无法通过编译。

调用:

f<int, 10>(1);

对于这种带有非类型参数的模板,通常需要使用尖括号 < > 进行显式实例化,清楚地告诉编译器每一个参数的值。

类模板#

前面是封装函数,使得它适应不同的类型。现在是封装类。

函数模板解决了通用算法的问题,而类模板则解决了通用数据结构(如栈、队列、数组等)的问题。

// 关键字 class 和 typename 在这里是等价的
template <class T1, class T2, ...>
class 类名 {
// ... 类成员声明 ...
// 在这里面可以像使用普通类型一样使用 T1, T2
};

比如:

template <class T>
class Stack {
private:
T buffer[100]; // 数据缓冲区,T 可以是 int, double 等任何类型
int top;
public:
Stack() { top = -1; }
void push(const T &x); // 压栈的元素是 T 类型
void pop(T &x); // 出栈的元素也是 T 类型
};

使用:

Stack<int> s1;

编译器就会在后台生成一个 int 版本的 Stack 类,其中所有的 T 都被替换成了 int

Stack<double> s2;

编译器则会生成一个 double 版本的 Stack 类。s1s2 是两种完全不同类型的对象。

如果成员函数在类外定义,语法会稍微复杂一点,必须同时包含模板声明和类的模板参数:

template <class T> // 1. 模板声明不能少
void Stack<T>::push(const T &x) { // 2. 类名后面要带 <T>
// ... 实现 ...
}

模板的复用#

WARNING

模板可以实现一种特殊的多态,称为参数化多态。模板是一段带有类型参数的代码,给该参数提供不同的类型就能得到多个不同的代码,即一段代码有多种解释。使用一个模板之前首先要对其进行实例化(用一个具体的类型去替代模板的类型参数),而实例化是在编译时进行的,一定要见到相应的源代码,否则无法进行实例化!因此,模板属于源代码复用。

函数模板的实例化可以是隐式的,也可以是显式的;而类模板的实例化则是显式进行的。

课本上这一块内容,主要意思就是:

模板是编译时就直接完成对应类型实现的代码实现的,如果文件中没有真正实例化,对应类型的代码不会被实现。C++编译器是先单独编译每个.cpp文件,分别生成.obj文件,最后再链接起来的。因此如果单个文件中,调用一个不是在本文件中定义的模板,会产生错误,因为模板所处的文件编译时,编译器根本不知道有这么个实例化,也就没生成对应类型实现的代码。

实现不完整,也会发生问题。

一个按照习惯会出错的地方就是这里:

S.h
template <class T>
class T {
public:
void f();
};
// f.cpp
#include "S.h"
template <class T>
void S<T>::f() {
// 具体实现...
}

我们习惯把具体函数的实现放在.cpp文件中,然后在main.cpp中:

main.cpp
#include "S.h"
int main(){
S<float> s1;
return 0;
}

这能通过编译吗?

编译器处理 main.cpp 时:

  • 它通过 #include "s.h" 看到了 S 这个模板的声明
  • 当它看到 S<int> s2; 这行代码时,它知道 S<int> 是一个合法的类型,也知道它应该有一个 f() 方法。
  • 但是,它没有 f()具体实现(因为实现在 s.cpp 里)。这时,编译器不会报错。它会想:“好的,我现在没有实现代码,但我相信别的某个 .cpp 文件会有,我就先在这里留个‘欠条’,等最后的链接阶段让链接器去把它们拼起来吧。”
  • 所以,main.cpp 顺利地编译成了一个 main.obj 文件,这个文件里包含了对 S<int>::f() 的一个引用(那张“欠条”)。

编译器处理 s.cpp 时:

  • 它看到了 S<T>::f() 的具体实现。
  • 但是,在这个文件里,没有任何地方用到了 S<int>
  • 由于模板的“按需实例化”原则,编译器觉得既然没人用 S<int>,那它就根本不会去生成 S<int>::f() 的具体机器码
  • 所以,s.cpp 也顺利地编译成了一个 s.obj 文件,但这个文件里不包含我们需要的 S<int>::f() 的代码。

链接器 (Linker) 工作时:

  • 链接器把 main.objs.obj 等所有 .obj 文件拿过来,准备把它们“粘”成一个可执行文件。
  • 它看到 main.obj 里的那张“欠条”,说:“我需要 S<int>::f() 的代码,谁有?”
  • 它翻遍了 s.obj 以及所有其他 .obj 文件,发现谁也没有提供这份代码。
  • 这时,链接器就只能报错了:“未定义的引用 (Undefined Reference)”。

解决方案就是把模板的定义和实现都放在一个.h文件中就行了。

2. 基于STL的编程#

STL (Standard Template Library),也就是C++的标准模板库

容器#

就是长度可变的同类型元素所构成的序列。

可以分为几类:

  1. 序列容器 (Sequence Containers):元素按顺序排列。
    • vector动态数组。支持快速随机访问(像数组一样用 []),在尾部增删元素很快。
    • list双向链表。支持在任意位置快速插入和删除,但随机访问很慢。
    • deque双端队列。是 vectorlist 的折中,支持在头部和尾部快速增删。
  2. 关联容器 (Associative Containers):元素自动排序。
    • set / multiset集合。内部是红黑树,元素唯一且有序。multiset 允许元素重复。
    • map / multimap映射。存储键-值对(key-value),根据 key 排序。map 的 key 唯一,multimap 允许 key 重复。
  3. 容器适配器 (Container Adapters)
    • stack。后进先出 (LIFO),通常基于 deque 实现。
    • queue队列。先进先出 (FIFO),通常基于 deque 实现。
    • priority_queue优先队列。最大(或最小)的元素总是在队首,通常基于 vector 和堆 (heap) 实现。

stack, queue, priority_queue 是“适配器”,它们限制了原有容器的功能,不支持迭代器,因此你不能把它们直接用在 sortfind 等通用算法上。

如果你想在容器里存储自己写的类对象,比如 vector<Student>,那么你的 Student 类可能需要满足一些条件:

  • 对于所有容器:你的类必须是可复制可赋值的,因为容器在内部可能需要拷贝或移动元素。这意味着你需要一个正常的拷贝构造函数赋值运算符 (operator=)。(通常编译器会为你生成默认的,但涉及指针时需小心深浅拷贝)。
  • 对于关联容器(如 map, set:因为它们需要对元素排序,所以你的类必须是可比较的。默认情况下,你需要重载小于号运算符 (operator<)

迭代器#

迭代器是连接容器和算法的“手”或“胶水”。它的设计思想就是模仿C++中的指针,但功能更通用、

一个指针最基本的两项操作是什么?

  1. 解引用(Dereferencing):通过 * 获取指针指向的内存里的值。
  2. 移动(Incrementing/Decrementing):通过 ++-- 移动指针,让它指向下一个或上一个内存地址。

迭代器就封装了这些核心功能,并根据其能力的大小,分成了五个不同的类别。

并非所有容器都能支持所有类型的移动。比如,链表就不能像数组那样随机跳到任意一个元素,它只能一个一个地移动。为了精确描述不同容器提供的遍历能力,STL定义了五种迭代器,它们的能力是层层递进的:

类别 (Category)主要能力能做什么通俗比喻
输出 (Output)*it = value; ++it;只写,向前移动,一次性打印机,只能顺着往下打印,不能回头读。
输入 (Input)value = *it; ++it;只读,向前移动,一次性键盘输入流,读过一个字符就没了,不能回头重读。
前向 (Forward)value = *it; *it = value; ++it;可读可写,向前移动,可多次读写一本只能往前翻的书,你可以反复读当前页。
双向 (Bidirectional)前向所有功能, --it;可读可写,可双向移动一本可以前后随意翻页的书。
随机访问 (Random-Access)双向所有功能, it + n; it - n; it[n];可读可写,双向移动,可随机跳跃一本有详细目录和页码的书,可以瞬间翻到任何一页。

这个关系就像一个能力升级树

image-20250617104120117
面向对象考前速通笔记 (3) 继承、模板
https://fuwari.vercel.app/posts/course/ooap/ooap-note-3/
作者
wegret
发布于
2025-06-16
许可协议
CC BY-NC-SA 4.0