封装

一、封装的含义

封装的字面意思就是“打包”。在C++中,封装有两层核心含义:

  1. 将数据(属性)和操作数据的方法(函数)捆绑在一起:形成一个统一的整体,这个整体就是“类”(Class)。
  2. 对外部隐藏对象的内部实现细节:仅提供一个可控的访问接口。

我们可以比喻一下:收音机

收音机内部有复杂的电路板、芯片和线路(数据成员函数实现)。它为你提供了一个简单的接口:开关、调频旋钮、音量按钮(公有成员函数)。你不需要知道内部是如何接收信号、如何解码、如何放大,你只需要通过接口来使用它。外壳将内部复杂的电路隐藏起来,防止你随意触碰导致损坏(防止数据被意外修改或破坏)。

C++库中封装的例子:

iteraotr迭代器,许多容器都有迭代器,但是他们对上层所提供的接口都相同(一系列运算符重载),然后容器又再次进行进行一次封装,给我们提供相同接口(begin(),end())。尽管他们底层的实现天差地别,但是在我们用户看来,他们所提供的接口和实现的功能都基本相同。

一个类型放在另一个类型里面,通过typedef和成员函数调整,封装出来一个全新的类型

这就是封装:隐藏实现,暴露接口

二、C++如何实现封装

C++通过三个访问说明符 来在语法层面实现封装,它们规定了类成员的“可见性”。

  1. private(私有)
  • 含义:被声明为private的成员,只能在类的内部被访问。
  • 作用:这是封装的核心。通常将数据成员不希望被外部知道的内部实现函数设置为private,从而将它们彻底隐藏起来。
  1. public(公有)
  • 含义:被声明为public的成员,可以在任何地方被访问(类内、类外、派生类等)。
  • 作用:这部分构成了类的接口。外界通过这些公有函数来与对象进行交互。
  1. protected(受保护)
  • 含义:介于private和public之间。能被类内部和派生类(子类) 访问,但不能被类外部的其他代码访问。
  • 作用:主要用在继承体系中,为派生类提供一种特殊的访问权限,同时仍对外部世界隐藏。

三、封装的意义

  1. 数据安全:通过将数据成员设置为私有(private),可以防止外部代码直接随意修改数据,从而保证数据的完整性和安全性。
  2. 实现隐藏:外部无法知道类的内部实现细节,只要接口不变,内部实现可以任意更改而不影响使用该类的代码。
  3. 降低耦合:由于隐藏了实现细节,使用类的代码只需要通过公有接口与类交互,这使得类与使用它的代码之间的耦合度降低。
  4. 便于维护:当内部实现需要修改时,只要公有接口保持不变,那么使用该类的代码就不需要改变。同时,由于数据和操作被封装在一起,相关功能的代码都集中在类中,便于维护。
  5. 提高代码可读性和可重用性:类提供了一个清晰的接口,使用类的用户只需要知道如何通过接口使用类,而不需要了解内部细节,使得代码更易于理解。并且,封装好的类可以在多个程序中重用。

总得来说,封装机制使得代码更加安全、灵活和易于维护,是面向对象设计的基础。

封装的最终目标是创建出高内聚、低耦合的类。每个类都像一个独立的、功能明确的“黑盒子”,内部高度自治,对外联系简单清晰。这是构建大型、复杂、可维护的软件系统的核心。

继承

一、继承的概念与定义

1.1 什么是继承?

继承是面向对象程序设计中最核心的代码复用机制。它允许我们在不修改原有类的基础上,通过扩展来创建新的类(称为派生类),从而形成类的层次结构。之前我们可能使用到的复用有函数复用,模板复用,而继承就是第三种复用,是类在设计层次上的复用

例如:Person 类作为基类,Student 和 Teacher 类作为派生类,复用 Person 中的成员变量和函数。

class Person {
protected:
    string _name = "liming";
    int _age = 20;
public:
    void Print() {
        cout << "name:" << _name << endl;
        cout << "age:" << _age << endl;
    }
};

class Student : public Person { //继承Person类,此时Student类中也存在Person类中的成员属性和方法,可以调用基类中的方法
protected:
    int _stuid; // 学号
};
class Teacher : public Person{
   protected:
    int _jobid; //工号
}

int main()
{
    Student st;
    Teacher te;
    
    st.Print();
    te.Print();
	
}

继承后,基类视为派生类的一部分

1.2 继承的访问控制

C++ 提供了三种继承方式:

  • public 继承
  • protected 继承
  • private 继承

它们影响基类成员在派生类中的访问权限:

基类成员访问权限 public 继承 protected 继承 private 继承
public public protected private
protected protected protected private
private 不可见 不可见 不可见

总结

  • private 成员在派生类中不可见,但仍被继承。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面 都不能去访问它。

  • 访问权限 = 访问限定符合继承方式两者中优先级高的。

    优先级如下:

    private > protected > public

  • 默认继承方式:class 为 private,struct 为 public。在实际中基本都会显示写出继承方式,增加代码可读性

  • 实际开发中常用 public 继承。


二、基类与派生类对象的赋值转换

  • 派生类 → 基类:允许赋值(称为“切片”或“切割”),包括对象、指针、引用。可以通俗的认为:基类是派生类的一部分,派生类中一定包含基类的所有成员,因此可以构造出来一个完整的基类
  • 基类 → 派生类:不允许直接赋值。与上一种情况相反,基类中没有所有的派生类成员,总会有部分成员没有合适的值去赋值,因此无法赋值
  • 基类指针/引用 → 派生类指针/引用:可通过强制类型转换,但不安全,除非基类指针原本就指向派生类对象。
Student s;
Person p = s;        //  切片
Person* pp = &s;     //  派生类指针给基类指针赋值
Person& rp = s;      //  派生类对象初始化基类引用

// s = p;            //  基类给派生类赋值错误
Student* ps = (Student*)pp; //  强制转换,算然可以,但是会存在越界访问

与隐式类型转换不同,隐式类型转换会用被转换的类型去构造出一个临时变量,然后用该临时变量去赋值或者拷贝构造出一个变量或者对象。而切片不会有临时变量的产生,他直接会将派生类成员拷贝或者调用拷贝构造去给基类成员赋值。

在这里插入图片描述

基类的指针会有指针偏移的特性,他总是能指向基类对象在派生类中的位置(可能在最开始,也可能在中间),


三、继承中的作用域

  • 基类和派生类有独立的作用域
  • 如果派生类中有与基类同名的成员,会隐藏(也叫重定义) 基类的成员。
  • 可使用 基类::成员 显式访问被隐藏的成员。
class Person {
public:

    void Show()
    {
 		cout << "Person::Show()" << endl;
    }
protected:
    int _num = 111; // 身份证号
};

class Student : public Person {
public:
    void Print() {
        cout << Person::_num << endl; // 显式访问基类成员
        cout << _num << endl;         // 访问派生类成员
    }
protected:
    int _num = 999; // 学号,隐藏基类的 _num
};

class Teacher : public Person {
public:
   void Show( int a) //与基类中成员函数同名,即使参数不同也构成隐藏
    {
 		cout << "Teacher::Show(), a = " a << << endl;
    }
protected:
    int _name = "wanglaoshi"; 
};

int main()
{
    Student s;
    s._num; //访问派生类自身的成员_num(学号)
    s.Person::_num; //访问基类中的_num(身份证号)
    
    Teacher t;
    t.Show(); //默认调用派生类成员方法
    t.Teacher::Show(5); //指定调用基类成员方法
}

注意:只要函数名相同也构成隐藏(不要求参数相同)。



四、菱形继承与虚拟继承

4.1 多继承

一个派生类同时继承两个或者两个以上的基类,就是多继承。

在这里插入图片描述

类D同时继承类B和类C,则是多继承

4.2 菱形继承

菱形继承指一个类继承自两个具有共同基类的类

在这里插入图片描述

会导致:

  • 数据冗余:共同基类的成员在最终派生类中有两份。
  • 二义性:访问共同成员时需指定路径。
#include<iostream>

using namespace std;
class A 
{ 
	public: 
	int _a;
};

class B : public A { 
	public: 
	int _b; 
};

class C : public A { 
	public: 
	int _c; 
};

class D : public B, public C {
	public: 
	int _d; 
};

D d;
d._a = 1; //  不知道访问那个,具有二义性
d.A::_a = 1; //  指定访问即可

4.3 虚拟继承(Virtual Inheritance)

使用 virtual继承可解决菱形继承问题,使基类在最终的派生类中只存在一份,解决了数据冗余,数据冗余解决了二义性的问题也不存在。

#include<iostream>

using namespace std;
class A 
{ 
	public: 
	int _a;
};

class B : virtual public A { 
	public: 
	int _b; 
};

class C : virtual public A { 
	public: 
	int _c; 
};

class D : public B, public C {
	public: 
	int _d; 
};
int main()
{
    D d;
	d._a = 0; //此时存在一份基类成员_a,不存在二义性
    return 0;
}

菱形虚拟继承在菱形的“腰部"位置,即继承公共基类时用虚拟继承(加上virtual关键字)

4.4 虚拟继承原理

通过改变派生类对象模型,在最终派生类中只保留一份虚基类。使用虚基表指针地址(派生类成员起始地址)➕偏移量 来访问共享的基类成员

#include<iostream>

using namespace std;
class A 
{ 
	public: 
	int _a;
};

class B : virtual public A { 
	public: 
	int _b; 
};

class C : virtual public A { 
	public: 
	int _c; 
};

class D : public B, public C {
	public: 
	int _d; 
};

int main()
{
	D d;
	d._b = 1; // 第28行代码
	d._c = 2; // 29
	d._d = 3; // 30
	d._a = 4; // 31

	return 0;

}

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

执行完这4行代码,在内存中我们可以看到所有成员的地址,我们可以推断,该菱形虚拟继承的对象模型如下图:在这里插入图片描述

用了虚拟继承后,在B和C类中分别继承的A类成员被单独放在了最后,且只有一份,可是我们会发现,B和C类成员中内存开始的前4个字节没有使用,这是怎么回事呢?

其实这四个字节是一个指针变量,用来存放虚基表地址,所以也叫虚基表指针,他们所指向的虚基表中存放着偏移量信息,具体如下:
在这里插入图片描述

可以看到,虚表中,前4个字节为0,这是为多态设计的内容,在这里暂不分析其含义,在多态的时候会详细说明。而第二行中,存放着一个16进制数14,即10进制数的20,这存放的是B派生类成员的起始地址与基类成员A的地址偏移量,即上图中0x006FFC18与0x006FFC2C的差值。

我们可以在看一下C派生类的来进行验证:
在这里插入图片描述

可以看到,与我们所说相同。

虚拟继承除了改变了最终派生类的对象模型外,还改变了中间派生类的对象模型,第一个存放虚基表指针,基类成员放到了最后,也通过虚基表地址➕偏移量去访问。也就是说,没有菱形继承,只要使用了虚拟继承,对象模型都会变为这种虚基标指针和自身成员的类型。这也是为了虚基类切片时的统一处理而设计的。

实践中可以设计多继承,但是尽量不要设计菱形继承,底层实现比较复杂,开销较大


五、派生类的默认成员函数

派生类的6个默认成员函数(构造、析构、拷贝构造、赋值、取地址)需注意:

  1. 构造函数:必须调用基类构造函数(可在初始化列表中调用)。
  2. 拷贝构造:必须调用基类拷贝构造。
  3. 赋值操作符:必须调用基类赋值操作符。
  4. 析构函数:先执行派生类析构,再自动调用基类析构。
  5. 析构函数会被特殊处理为destructor(),因此与基类析构函数构成隐藏关系。
class Student : public Person {
public:
    Student(const char* name, int num)  //构造函数,必须调用基类构造
        : Person(name), _num(num) 
    {}
    
    Student(const Student& s)  //拷贝构造也必须调用基类拷贝构造
        : Person(s), _num(s._num) 
    {}
    
    Student& operator=(const Student& s) { //赋值拷贝也必须调用基类赋值拷贝,但是*this指针是派生类的,也有没基类的任何表示,所以需要指定类作用域 + 显示调用基类赋值拷贝构造
        if (this != &s) {
            Person::operator=(s);
            _num = s._num;
        }
        return *this;
    }
    
    ~Student() {
        // 自动调用 ~Person()
    }
};

在派生类中,直接调用 ~Person() 会调用到派生类的析构,因为析构函数在底层都将析构函数重命名为destructor(),形成了函数隐藏

需要指定作用域在调用。Person::~person() ,这样就可以显示调用到基类析构

注意:在析构函数中,如果我们显示调用基类的析构函数,那么在最后还是会自动的再次调用析构函数,导致重复析构的问题。这是因为要保证先调派生类析构,在调基类析构。

那么为什么要保证这个顺序呢?

如果我们在派生类中显示调用基类析构,此时我们依然在派生类作用域里,还是可以调用基类的属性和方法,若是在我们显示调用析构后我们还使用基类的属性和方法,则会造成一系列问题,为了防止这个问题,编译器强制在派生类析构执行完之后自动调用基类派生,无需我们手动调用。

除了析构函数的顺序,构造函数也有顺序,不过是先基类,在派生类,因此派生类在整个生命周期调用函数顺序如下图:

在这里插入图片描述

六、继承与友元

友元关系不能继承。基类的友元函数不能访问派生类的私有或保护成员。

class Person {
    friend void Display(const Person& p, const Student& s);
protected:
    string _name;
};

class Student : public Person {
protected:
    int _stuNum;
};

void Display(const Person& p, const Student& s) {
    cout << p._name << endl;     // 正确
    cout << s._stuNum << endl;   // 错误,不能访问 Student 的 protected 成员
}

七、继承与静态成员

基类中定义的 static 成员在整个继承体系中只有一份实例,所有派生类共享。

class Person {
public:
    Person() //构造函数中让静态成员自增,可以统计基类和派生类所有个数
    {
		_count++;
    }
    static int _count;
};
int Person::_count = 0;

class Student : public Person { /* ... */ };
class Graduate : public Student { /* ... */ };

// 所有类共享同一个 _count

如何统计基类和派生类个数?

在基类的构造函数中让静态整形成员自增,析构中自减,这样无论是构造基类还是派生类都可以让该变量增加,这样就可以统计该继承体系下所有对象的个数

八、继承和组合

继承和组合都是复用,那我们如何进行选择呢?

在实践中,我们优先使用对象组合,而不是类继承

为什么?

  1. 降低耦合,提高弹性:组合让类之间的关系更松散。就像一台电脑,CPU、内存、硬盘是组合在一起的,你可以轻松地升级任何一个部件,而不需要动其他部分。如果用继承,就好像把CPU焊死在主板上,难以更换。
  2. 保持封装:组合强制你通过公共接口交互,不会破坏被组合类的封装。
  3. 功能更灵活:一个类可以轻松地组合多个不同类的对象,从而拥有多种能力,避免了多继承的复杂性。

那么,什么时候该用继承?

继承并非一无是处,它在以下两种场景下是不可替代的:

  1. 当存在清晰的、永久的 “is-a” 关系时:就像“学生是人”、“狗是动物”这种在问题域中天然存在的关系。这种关系在软件的整个生命周期内通常不会改变。
  2. 当你需要实现多态时:这是继承最关键的作用。你需要通过基类的指针或引用来调用派生类的重写函数,这是实现面向对象设计模式和处理运行时行为变化的基石。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐