GESP六级专题之类与对象

类与对象

绪论

写一个很小的程序时,我们可以把变量和函数都放在一起,程序变大以后,数据与操作数据的函数容易散落各处。类的作用,就是把一个事物的数据和它能完成的操作组织成一个整体。例如,一个学生既有姓名、分数等状态 ,也有修改分数、判断是否及格等行为。把它们放进 Student 类,程序会更接近我们理解现实事物的方式。

类、对象与封装

从蓝图到具体对象

  • 类:描述一类事物共同的数据和操作,是一种用户自定义类型
  • 对象:按照类创建出的具体实例
  • 数据成员:保存对象的状态
  • 成员函数:描述对象可以完成的操作

类像一张蓝图,对象才是按蓝图造出的具体产品。定义 Student 类并不会自动产生某个学生,写出 Student a; 后才创建了对象 a。

类的基本语法

c++ 复制代码
class 类名
{
    private:
      // 私有成员:主要用于保存内部状态
    public:
      // 公有成员:向类外提供操作接口
    protected:
      // 保护成员:主要供本类和派生类使用
}; // 这里的分号不能漏

三个访问说明符的基本含义:

成员位置 本类成员函数 派生类成员函数 普通类外代码
public 可以 可以 可以
protected 可以 可以 不可以
private 可以 不可直接访问 不可以

注意:这里的派生类不能直接访问基类 private 成员,不等于这些成员消失了。它们仍位于对象的基类部分,只能借助基类公开或保护的接口间接访问。

封装不是简单隐藏

封装有两个层次:

  1. 把相关的数据与函数组织在同一个类中
  2. 隐藏内部实现,只允许通过受控接口修改状态

自动售货机不会让顾客直接碰内部电路和库存,而是提供投币口和按钮。

同样,类把内部数据设为 private,再用 public 成员函数检查输入、维护规则。

示例:不会进入非法状态的成绩类

c++ 复制代码
#include <iostream>
using namespace std;
class Score
{
    private:
        int value;
    public:
        explicit Score(int v = 0);
        bool set(int v);
        int getValue() const;
        bool passed() const;
};
Score::Score(int v) : value(0){ set(v); }
bool Score::set(int v)
{
    if(v < 0 || v > 100)return false;
    value = v;
    return true;
}
int Score::getValue() const{ return value; }
bool Score::passed() const{ return value >= 60; }
int main()
{
    Score score(85);
    if(!score.set(101))cout<<"修改失败:成绩必须在 0~100 之间"<<endl;
    cout<<"当前成绩:"<<score.getValue()<<endl;
    cout<<(score.passed() ? "及格" : "不及格")<<endl;
    return 0;
}

对应输出为:

text 复制代码
修改失败:成绩必须在 0~100 之间
当前成绩:85
及格

逐步理解:

  1. value 是私有成员,类外不能写 score.value = 101;
  2. 构造函数先把 value 设为 0,再调用 set(v)
  3. set 检查参数是否合法,传入 101 时返回 false
  4. 修改失败后,原来的合法状态 85 没有被破坏
  5. getValue() 和 passed() 后面的 const 表示它们不会修改当前对象

这才是封装的重要价值,不仅防止别人看见数据,更要让对象始终保持合法状态。

class 与 struct 的区别

在 C++ 中,两者都可以拥有数据成员、成员函数、构造函数、继承和虚函数。最常考的默认规则是:

写法 成员的默认访问权限 继承的默认方式
class private private
struct public public
c++ 复制代码
class A
{
		int x; // 默认 private
};
struct B
{
		int x; // 默认 public
};

工程中通常用 struct 表达主要保存数据的简单结构,用 class 表达需要维护规则和隐藏实现的对象,但这是一种习惯,不是语法强制要求。

构造函数、初始化列表与析构函数

对象的出生与离开

  • 构造函数在对象创建时负责初始化对象
  • 析构函数在对象生命周期结束时负责收尾

构造函数的语法与特点

c++ 复制代码
class Point
{
    private:
        int x;
        int y;
    public:
        Point() : x(0), y(0) {}
        Point(int xx, int yy) : x(xx), y(yy) {}
};

构造函数具有以下特点:

  • 名称与类名相同
  • 没有返回类型,连 void 也不能写
  • 可以重载,只要参数列表能够区分
  • 创建对象时由语言规则调用,不能像普通成员函数那样对一个已经存在的对象"再构造一次"

Point p; 调用无参构造函数;Point q(3, 4); 调用带两个参数的构造函数。

默认构造函数

能够在不提供实参时被调用的构造函数,称为默认构造函数。下面是两种不同的设计方式:

c++ 复制代码
Point(); // 方式一:无参默认构造函数
Point(int x = 0, int y = 0); // 方式二:所有参数都有默认值

不要在同一个类中同时提供上面两个版本,否则 Point p; 不知道该选谁,会发生调用二义性。

如果类没有声明任何构造函数,编译器通常会隐式声明默认构造函数。一旦用户声明了构造函数,编译器通常不会再额外生成可无参调用的默认构造函数。

c++ 复制代码
class Point
{
    public:
        Point(int x, int y) {}
};
Point a; // 编译错误:没有 Point()
Point b(1, 2); // 正确

需要默认构造时,可以明确写出:

c++ 复制代码
Point() = default;

但只有当所有需要默认初始化的基类和成员也允许这样做时,= default 才可用。

初始化列表

c++ 复制代码
class Student
{
    private:
        const int id;
        string name;
    public:
        Student(int number, string studentName) : id(number), name(studentName) {}
};

冒号后的 id(number), name(studentName) 就是成员初始化列表。格式是:成员名(用于初始化它的表达式),初始化列表不是只能写在类内。构造函数在类外定义时同样可以使用:

c++ 复制代码
class Point
{
    private:
        int x;
        int y;
    public:
      	Point(int xx, int yy);
};
Point::Point(int xx, int yy) : x(xx), y(yy) {}

以下情况必须或应优先使用初始化列表:

  • 初始化 const 数据成员
  • 初始化引用成员
  • 调用没有默认构造函数的成员对象
  • 初始化基类部分
  • 避免先默认构造、再赋值造成的重复工作

成员初始化顺序

c++ 复制代码
class Demo
{
    private:
        int first;
        int second;
    public:
      	Demo() : second(first + 1), first(10) {}
};

尽管列表先写 second,真正的顺序仍是先初始化 first、再初始化 second,因为 first 在类中先声明。为避免误读和警告,初始化列表最好也按照成员声明顺序书写为:Demo() : first(10), second(first + 1) {}

析构函数

析构函数名称是在类名前加 ~:

c++ 复制代码
class FileGuard
{
    public:
        ~FileGuard()
        {
          // 对象离开时的收尾工作
        }
};

析构函数:

  • 没有返回类型
  • 没有参数
  • 不能重载,一个类至多有一个析构函数
  • 普通局部对象离开作用域时自动析构
  • 使用 new 创建的对象,要在相应所有权规则下用 delete,或交给智能指针管理

如果没有显式编写析构函数,编译器会生成一个。它不是简单的什么都不做,其中的 string、vector 等类类型成员仍会被正确析构。

观察对象生命周期

先不要运行,试着在纸上预测输出。

c++ 复制代码
#include <iostream>
#include <string>
using namespace std;
class Trace
{
    private:
      	string name;
    public:
        explicit Trace(string n) : name(n)
        {
          cout<<"构造:"<<name<<endl;
        }
        ~Trace()
        {
          cout<<"析构:"<<name<<endl;
        }
};
void test()
{
    Trace local("普通局部对象");
    static Trace once("静态局部对象");
    {
      	Trace inner("内层对象");
    }
}
int main()
{
    test();
    cout<<"----- 第二次调用 -----"<<endl;
    test();
    cout<<"----- main 即将结束 -----"<<endl;
    return 0;
}

对应输出为:

c++ 复制代码
构造:普通局部对象
构造:静态局部对象
构造:内层对象
析构:内层对象
析构:普通局部对象
----- 第二次调用 -----
构造:普通局部对象
构造:内层对象
析构:内层对象
析构:普通局部对象
----- main 即将结束 -----
析构:静态局部对象

关键观察:

  1. local 每次进入 test() 都会构造,离开函数时析构
  2. inner 离开内层大括号时立即析构
  3. once 只在第一次执行到它的定义时构造一次
  4. 静态局部对象在程序正常结束时析构

this 指针、常成员与静态成员

this 的含义

每个非静态成员函数都针对某个具体对象执行。函数内部可以使用隐含的 this 指针,它指向正在调用这个函数的对象。

c++ 复制代码
class Box
{
	private:
			int width;
	public:
      void setWidth(int width)
      {
        	this->width = width;
      }
};

在 box.setWidth(10); 中,this 指向 box。当形参与成员同名时,this->width 能清楚表示成员,右侧 width 表示参数。

return *this 与链式调用

this 是指针,*this 就是当前对象。若成员函数返回当前对象的引用,就可以连续调用:

c++ 复制代码
#include <iostream>
using namespace std;
class Counter
{
    private:
      	int value;
    public:
        explicit Counter(int v = 0) : value(v) {}
        Counter &add(int delta)
        {
            this->value += delta;
            return *this;
        }
        Counter &reset()
        {
            this->value = 0;
            return *this;
        }
        int getValue() const
        {
          	return this->value;
        }
};
int main()
{
    Counter counter;
    counter.add(3).add(5).add(2);
    cout<<counter.getValue()<<endl; // 10
    counter.reset().add(7);
    cout<<counter.getValue()<<endl; // 7
    return 0;
}

对应输出为:

c++ 复制代码
10
7

为什么返回 Counter& 而不是 Counter?返回引用时,下一次调用仍作用于原对象,如果按值返回,可能产生一个副本,语义和开销都会不同。

函数末尾的 const

c++ 复制代码
int getValue() const;

末尾的 const 表示这个非静态成员函数承诺不修改当前对象的普通数据成员。常对象只能调用常成员函数(静态成员函数另当别论):

c++ 复制代码
const Counter counter(10);
cout<<counter.getValue(); // 正确:getValue() 是常成员函数
counter.add(5);  // 编译错误:add() 可能修改对象

只读函数应尽量写成常成员函数,它带来两点好处:

  • 常对象和指向常对象的引用可以调用
  • 编译器帮助检查函数没有意外修改状态

常成员函数中,this 可理解为指向常对象的常指针,所以不能通过它修改普通成员。

避免混淆两种 const 指针

c++ 复制代码
Counter normal(10);
Counter other(20);
Counter *const p1 = &normal; // 常指针:p1 不能改指向,对象可以改
const Counter *p2 = &normal; // 指向常对象的指针:p2 可改指向
p1->add(1);  // 正确:能通过 p1 修改 normal
p2 = &other; // 正确:p2 本身可以改指向
// p2->add(1); // 错误:不能经 p2 修改所指对象

判断方法:从变量名向两侧读。

  • Counter* const p1:p1 本身是 const
  • const Counter* p2:p2 指向的 Counter 是 const

因此,常指针会使其指向的对象不能修改是不准确的,要看 const 修饰的是指针还是所指对象。

static 数据成员

普通数据成员每个对象各有一份,静态数据成员属于类,通常只有一份,由所有对象共享。

  • 对象 a 有自己独立的 id、name 和 score
  • 对象 b 也有自己独立的 id、name 和 score
  • 两个对象共同使用 Player::nextId
c++ 复制代码
#include <iostream>
#include <string>
using namespace std;
class Player
{
    private:
        static int nextId;
        int id;
        string name;
        int score;
    public:
        explicit Player(string n) : id(++nextId), name(n), score(0) {}
        Player &addScore(int delta)
        {
            score += delta;
            return *this;
        }
        int getId() const
        {
          	return id;
        }
        int getScore() const
        {
          	return score;
        }
        static int issuedCount()
        {
          	return nextId;
        }
};
int Player::nextId = 0;
int main()
{
    Player a("小明");
    Player b("小红");
    a.addScore(10).addScore(20);
    cout<<"a: "<<a.getId()<<' '<<a.getScore()<<endl;
    cout<<"b: "<<b.getId()<<' '<<b.getScore()<<endl;
    cout<<"共发出编号:"<<Player::issuedCount()<<endl;
    return 0;
}

对应输出为:

c++ 复制代码
a: 1 30
b: 2 0
共发出编号:2

关键规则:

  • 普通静态数据成员通常要在类外提供一次定义,如 int Player::nextId = 0;
  • 静态成员函数没有 this 指针,因为调用它时不一定存在具体对象
  • 静态成员函数可以直接访问静态成员,不能直接访问某个对象的非静态成员
  • 推荐用 类名::静态成员函数() 表达它属于整个类

复制构造、复制赋值与资源管理

两句相似的代码

c++ 复制代码
Card b = a; // b 还不存在:创建 b,并用 a 初始化
b = a; // b 已经存在:把 a 的状态赋给 b

它们分别调用:

  • 复制构造函数:用已有对象创建新对象
  • 复制赋值运算符:两个对象都已存在,用右侧状态替换左侧状态
c++ 复制代码
#include <iostream>
using namespace std;
class Card
{
    private:
      	int value;
    public:
        explicit Card(int v = 0) : value(v)
        {
          	cout<<"普通构造:"<<value<<endl;
        }
        Card(const Card &other) : value(other.value)
        {
          	cout<<"复制构造"<<endl;
        }
        Card &operator=(const Card &other)
        {
            cout<<"复制赋值"<<endl;
            if(this != &other)
            {
              	value = other.value;
            }
            return *this;
        }
        int getValue() const
        {
          	return value;
        }
};
int main()
{
    Card a(10); // 普通构造
    Card b = a; // 复制构造
    Card c; // 普通构造
    c = a;  // 复制赋值
    Card &same = c;
    c = same; // 自赋值测试
    cout<<b.getValue()<<' '<<c.getValue()<<endl;
    return 0;
}

对应输出为:

c++ 复制代码
普通构造:10
复制构造
普通构造:0
复制赋值
复制赋值
10 10

复制构造函数为什么通常接收 const Card&?

  • 使用引用,避免为了传参再次复制
  • 使用 const,允许复制常对象,并承诺不改原对象

复制赋值为什么常返回 Card&?为了支持 a = b = c; 这样的连续赋值。if (this != &other) 用于识别 c = c; 自赋值。

编译器生成的复制做了什么?

没有自定义复制操作时,编译器通常进行逐成员复制

  • 新对象的第一个成员复制原对象的第一个成员
  • 新对象的第二个成员复制原对象的第二个成员
  • 其余成员也依次按照各自类型的复制规则进行复制

如果成员是 int、double、string、vector 等具有正常值语义的类型,这通常正是想要的结果。例如两个 vector 对象复制后,各自拥有可独立修改的内容。

危险主要来自拥有资源的裸指针:逐成员复制只会复制地址,两个对象可能以为自己都拥有同一块内存。

浅复制与深复制

浅复制一个"拥有动态内存"的指针可能导致:

  • 一个对象释放后,另一个对象的指针悬空
  • 两个对象析构时重复释放同一地址
  • 修改一个对象的数据,另一个对象也意外变化

继承与访问控制

是一个还是有一个?

继承表达一种 is-a(是一个) 关系:

  • 学生是一个人:class Student : public Person 比较合理
  • 狗是一种动物:class Dog : public Animal 比较合理
  • 汽车有一个发动机:更适合把 Engine 作为 Car 的成员,而不是让汽车继承发动机

基类保存共同部分,派生类复用它并增加自己的数据或操作。

公有继承的基本写法

c++ 复制代码
#include <iostream>
#include <string>
using namespace std;
class Person
{
    private:
      	string name;
    protected:
      	int age;
    public:
        Person(string name,int age);
        string getName() const;
};
class Student : public Person
{
    private:
      	int score;
    public:
        Student(string name,int age,int score);
        void print() const;
};
Person::Person(string name,int age):name(name),age(age){}
string Person::getName() const
{
		return name;
}
Student::Student(string name,int age,int score):Person(name,age),score(score){}
void Student::print() const
{
    // 通过基类公有接口访问其私有数据,派生类可直接访问 protected 成员
    cout<<getName()<<' '<<age<<' '<<score<<endl;
}
int main()
{
    Student student("小明", 12, 95);
    student.print();
    cout<<student.getName()<<endl;
    // cout<<student.age;  // 错误:类外不能访问 protected 成员
    return 0;
}

对应输出为:

复制代码
小明 12 95
小明

name 仍然存在于 Student 对象的 Person 基类部分,只是 Student 不能直接写 name,因此通过 getName() 访问。

不同继承方式下的权限变化

基类中的成员 公有继承后 保护继承后 私有继承后
public 派生类中仍为 public 变成 protected 变成 private
protected 仍为 protected 仍为 protected 变成 private
private 派生类均不可直接访问 派生类均不可直接访问 派生类均不可直接访问

GESP 常见代码以公有继承为主,它保留派生类是一个基类的公开关系。

派生类如何构造基类部分?

c++ 复制代码
Student::Student(string name,int age,int score):Person(name,age),score(score){}

Person(name, age) 的含义是:调用匹配的 Person 构造函数,初始化当前对象的基类部分。它不是在派生类构造函数体中手动调用普通函数。

如果基类没有无参构造函数,而派生类初始化列表又没有指定基类构造函数,编译器会尝试调用不存在的 Base(),从而报错。

  • 构造顺序固定为:基类 -> 按声明顺序构造成员对象 -> 派生类构造函数体
  • 析构顺序恰好相反:派生类析构函数体 -> 按相反顺序析构成员对象 -> 基类

基类析构函数会由语言规则自动调用,不需要、也不应该由派生类析构函数手动调用。

可以通过以下代码验证构造与析构顺序:

c++ 复制代码
#include <iostream>
using namespace std;
class Base
{
    public:
        Base()
        {
          	cout<<"1. 构造 Base"<<endl;
        }
        ~Base()
        {
            cout<<"6. 析构 Base"<<endl;
        }
};
class Member
{
    public:
        Member()
        {
          	cout<<"2. 构造 Member"<<endl;
        }
        ~Member()
        {
          	cout<<"5. 析构 Member"<<endl;
        }
};
class Derived : public Base
{
    private:
      	Member member;
    public:
        Derived()
        {
          	cout<<"3. 构造 Derived"<<endl;
        }
        ~Derived()
        {
          	cout<<"4. 析构 Derived"<<endl;
        }
};
int main()
{
    Derived object;
    return 0;
}

对应输出为:

c++ 复制代码
1. 构造 Base
2. 构造 Member
3. 构造 Derived
4. 析构 Derived
5. 析构 Member
6. 析构 Base

名字隐藏不是重载

c++ 复制代码
class Base
{
    public:
      	void show(int x) {}
};
class Derived : public Base
{
    public:
      	void show(string text) {}
};

Derived::show 会隐藏基类中同名的 show 集合,它们不因为参数不同就自动成为普通意义上的同一重载集合,可在派生类中写 using Base::show; 把基类版本引入。更重要的是:同名也不等于重写。动态多态中的重写必须满足基类函数为虚函数,派生类函数签名匹配,并建议写 override。

多态

假设函数 introduce 只知道参数是动物,但实际传入的可能是狗,也可能是猫:

  • introduce(dog) -> 应输出"汪"
  • introduce(cat) -> 应输出"喵"

我们希望调用语句始终是 animal.speak(),真正执行哪个版本则由实际对象决定。这就是运行时多态的直观含义。

c++ 复制代码
#include <iostream>
#include <string>
using namespace std;
class Animal
{
    public:
        virtual string speak() const
        {
          	return "未知声音";
        }
        virtual ~Animal() = default;
};
class Dog : public Animal
{
    public:
        string speak() const override
        {
          	return "汪";
        }
};
class Cat : public Animal
{
    public:
        string speak() const override
        {
          	return "喵";
        }
};
void introduce(const Animal &animal)
{
		cout<<animal.speak()<<endl;
}
int main()
{
    Dog dog;
    Cat cat;
    introduce(dog);
    introduce(cat);
    return 0;
}

对应输出为:

c++ 复制代码
汪
喵

要发生这种动态绑定,通常要满足:

  1. 基类把函数声明为 virtual
  2. 派生类提供签名匹配的重写版本,推荐写 override
  3. 通过基类指针或基类引用调用
  4. 指针或引用实际指向某个派生类对象

虚函数是真实存在的函数。虚不是函数不存在,而是表示通过基类指针或引用调用时,允许根据对象的动态类型选择最终版本。

静态类型与动态类型

c++ 复制代码
Dog dog;
Animal *p = &dog;
p->speak();
  • p 的静态类型:写在程序中的 Animal*
  • p 所指对象的动态类型:运行时实际是 Dog
  • 因为 speak 是虚函数,最终调用 Dog::speak()

如果基类函数没有 virtual,通过 Animal* 调用时通常按静态类型绑定到 Animal 版本,不会获得这里所说的运行时多态。

override 是编译器检查,不只是注释

c++ 复制代码
class Base
{
    public:
      	virtual void print() const {}
};
class Derived : public Base
{
    public:
      	void print() override {} // 编译错误:少了末尾 const
};

const 也是函数签名匹配的一部分。写上 override 后,编译器会立刻告诉我们没有真正重写。修正为:

c++ 复制代码
void print() const override {}

这也是为什么派生类重写虚函数时应养成写 override 的习惯。

重载、隐藏、重写区别

概念 发生位置 关键条件 是否依赖 virtual
重载 通常在同一作用域 同名,参数列表不同
隐藏 派生类与基类之间 派生类声明同名成员,基类同名集合被隐藏
重写 派生类与基类之间 基类虚函数,派生类签名匹配

返回类型不同不能单独构成重载:

c++ 复制代码
int f(int x);
double f(int x); // 错误:只有返回类型不同

为什么多态通常用引用或指针

c++ 复制代码
Dog dog;
Animal a = dog; // 把 Dog 按值复制成 Animal,派生部分被切掉
a.speak();      // 这里 a 就是一个独立的 Animal 对象

这个现象叫对象切片。按值放入基类对象时,只保留基类部分。因此,多态接口通常写成:

c++ 复制代码
void f(const Animal &animal); // 基类引用
void g(const Animal *animal); // 基类指针

而不是 void h(Animal animal);。

虚析构函数

问题场景

c++ 复制代码
Base *p = new Derived;
delete p;

如果 Base 的析构函数不是虚函数,通过基类指针删除实际的派生类对象会产生未定义行为。不能简单说成肯定只执行基类析构,标准给出的结论更严重:程序行为没有可靠保证。正确做法:准备作为多态基类的类型,应声明虚析构函数。可运行示例:

c++ 复制代码
#include <iostream>
using namespace std;
class Base
{
    public:
        virtual ~Base()
        {
          	cout<<"析构 Base"<<endl;
        }
};
class Derived : public Base
{
    public:
        ~Derived() override
        {
          	cout<<"析构 Derived"<<endl;
        }
};
int main()
{
    Base *pointer = new Derived;
    delete pointer;
    return 0;
}

输出:

text 复制代码
析构 Derived
析构 Base

先析构最具体的派生部分,再逐层析构基类部分,整个对象才被完整清理。

构造函数为什么不能是虚函数

构造期间对象还在形成,动态类型对应的完整对象尚未构造完毕,所以构造函数不能声明为 virtual。析构函数则可以、而且多态基类通常应该是虚函数。

纯虚函数与抽象类

当基类只负责规定接口

所有图形都可以计算面积,但普通图形没有统一的具体面积公式。我们可以让基类只规定接口:

c++ 复制代码
virtual double area() const = 0;

= 0 表示这是纯虚函数,含有未被实现的纯虚函数的类是抽象类,不能直接创建对象。

c++ 复制代码
#include <iomanip>
#include <iostream>
using namespace std;
class Shape
{
    public:
        virtual double area() const = 0;
        virtual const char *name() const = 0;
        virtual ~Shape() = default;
};
class Rectangle : public Shape
{
    private:
        double width;
        double height;
    public:
      	Rectangle(double width,double height):width(width),height(height) {}
        double area() const override
        {
          	return width*height;
        }
        const char *name() const override
        {
          	return "矩形";
        }
};
class Circle : public Shape
{
    private:
      	double radius;
    public:
      	explicit Circle(double radius):radius(radius) {}
        double area() const override
        {
            const double pi = 3.141592653589793;
            return pi*radius*radius;
        }
        const char *name() const override
        {
          	return "圆";
        }
};
void printShape(const Shape &shape)
{
		cout<<shape.name()<<"面积:"<<fixed<<setprecision(2)<<shape.area()<<endl;
}

int main()
{
    Rectangle rectangle(3.0, 4.0);
    Circle circle(2.0);
    printShape(rectangle);
    printShape(circle);
    return 0;
}

对应输出为:

c++ 复制代码
矩形面积:12.00
圆面积:12.57

抽象类作用

抽象类虽然不能直接实例化,但仍然可以:

  • 拥有构造函数和析构函数
  • 拥有数据成员
  • 拥有已经实现的普通成员函数和虚函数
  • 声明指向它的指针或引用
  • 作为派生类的统一接口

派生类只有正确重写全部继承而来的纯虚函数,才会成为可实例化的具体类。

常见错误

c++ 复制代码
Shape shape; // 错误:Shape 是抽象类
Shape *p = nullptr; // 正确:可以声明抽象类指针

如果派生类把函数写成了不匹配的签名,它仍可能是抽象类:

c++ 复制代码
class BadCircle : public Shape
{
    public:
        double area()
        {
          	return 0;
        } // 少了 const,没有重写 area() const
        const char *name() const override
        {
          	return "错误示例";
        }
};

写 override 可以让这种错误马上暴露。

友元与类模板

友元函数

普通非成员函数不能访问类的私有成员,类可以用 friend 把访问权限授予某个函数:

c++ 复制代码
#include <iostream>
using namespace std;
class Point
{
    private:
        int x;
        int y;
    public:
        Point(int x, int y);
        friend bool samePosition(const Point &a, const Point &b);
};
Point::Point(int x, int y) : x(x), y(y){}
bool samePosition(const Point &a, const Point &b)
{
		return a.x == b.x && a.y == b.y;
}
int main()
{
    Point a(2, 3);
    Point b(2, 3);
    cout<<(samePosition(a, b) ? "相同" : "不同")<<endl;
    return 0;
}

对应输出为:

c++ 复制代码
相同

samePosition 仍然是普通函数,不是 Point 的成员函数,所以调用时写 samePosition(a, b),而不是 a.samePosition(b)。

友元类

c++ 复制代码
class Vault
{
    private:
        int secret = 42;
        friend class Inspector;
};

这表示 Inspector 的成员函数可以访问 Vault 的私有、保护成员。要准确记住:

  • 友元关系是单向的:Inspector 是 Vault 的友元,不代表反过来也成立
  • 友元关系不传递:A 信任 B、B 信任 C,不代表 A 信任 C
  • 友元关系不继承
  • Inspector 的成员函数仍属于 Inspector,不会变成 Vault 的成员

友元会扩大访问范围,应在确实需要紧密协作时使用,不能用它绕开合理的封装设计。

类模板

如果盒子类既要保存整数,又要保存字符串,与其复制两份代码,不如写类模板:

c++ 复制代码
#include <iostream>
#include <string>
using namespace std;
template <typename T> class Box
{
    private:
      	T value;
    public:
        explicit Box(const T &value) : value(value) {}
        const T &getValue() const
        {
          	return value;
        }
        void set(const T &newValue)
        {
          	value = newValue;
        }
};
int main()
{
    Box<int> number(42);
    Box<string> word("GESP");
    cout<<number.getValue()<<endl;
    cout<<word.getValue()<<endl;
    return 0;
}

对应输出为:

复制代码
42
GESP

Box<int> 与 Box<string> 是由同一个模板产生的不同具体类型。vector<int>、vector<string> 也是同样的思想。

多重继承、菱形继承与组合

多重继承

C++ 允许一个派生类拥有多个直接基类:

c++ 复制代码
class Printer
{
    public:
      	void print() {}
};
class Scanner
{
    public:
      	void scan() {}
};
class AllInOne : public Printer, public Scanner
{
};

AllInOne 同时获得打印和扫描接口。如果多个基类中存在同名成员,调用时可能需要写 object.Printer::work() 指定来源。

菱形继承为什么会产生两份基类

如果 B 和 C 都普通继承 A,D 中会有两条到达 A 的路径,也通常有两个 A 基类子对象。

c++ 复制代码
class A
{
    public:
      	int n = 0;
};
class B : public A
{
};
class C : public A
{
};
class D : public B, public C
{
};
D d;
d.B::n = 1; // 指定经 B 到达的那份 A::n
d.C::n = 2; // 指定经 C 到达的那份 A::n

不能简单写 d.A::n 来区分,因为仍然没有说明走 B 还是 C 这条路径。

虚继承

c++ 复制代码
class B : virtual public A
{
};
class C : virtual public A
{
};
class D : public B, public C
{
};

虚继承可以让 D 中共享一份 A 虚基类子对象,最底层派生类负责初始化虚基类。该机制语法和构造顺序较复杂,六级阶段理解"它用来解决菱形继承中的重复基类"即可。

继承不一定优于组合

继承表示是一个的关系,例如 Dog 是一种 Animal,也就是 is-a。组合表示有一个的关系,例如 Car 拥有一个 Engine,也就是 has-a。当两个类型不是稳定的是一个关系时,优先考虑把一个对象作为另一个对象的成员。组合通常耦合更低,也更容易替换内部实现。

默认继承方式

c++ 复制代码
class D1 : Base
{
}; // class 默认 private 继承
struct D2 : Base
{
}; // struct 默认 public 继承

不能笼统地说不写继承方式就一定是私有继承,必须先看派生类型使用 class 还是 struct。

相关推荐
anOnion1 小时前
构建无障碍组件之Listbox Pattern
前端·html·交互设计
凌涘1 小时前
前端路由(三):鉴权、拦截与重定向
前端
麦聪聊数据1 小时前
数据治理 ROI(下):算清投入产出比,小切口落地快速验证价值
数据库
dyxal1 小时前
SSH本地端口转发完全解析:像“挖掘隧道”一样安全访问远程数据库
数据库·安全·ssh
Slow菜鸟1 小时前
第3篇:实操落地篇 · 3套企业标准工作流(IDEA可视化版)
java·ide·intellij-idea
weixin_440784111 小时前
Java基础常见题
java·开发语言·python·java基础
半个落月2 小时前
从零梳理 React Router:路由、懒加载、嵌套页面与登录鉴权
前端·react.js
掉鱼的猫2 小时前
Hot-Plug 实战:运行期管理 Solon 插件
java·hotfix
markinmarkin2 小时前
Spring 中有哪些方式可以把Bean 注入到IOC 容器?
java·spring