当前位置:   article > 正文

[C++] 深入理解面向对象编程特性 : 继承

[C++] 深入理解面向对象编程特性 : 继承

Kevin的技术博客.png

文章目录



继承的概念与定义

面向对象程序设计中最重要的一个概念是继承。继承允许我们依据另一个类来定义一个类,这使得创建和维护一个应用程序变得更容易。这样做,也达到了重用代码功能和提高执行效率的效果。
当创建一个类时,您不需要重新编写新的数据成员和成员函数,只需指定新建的类继承了一个已有的类的成员即可。这个已有的类称为基类,新建的类称为派生类
cpp-inheritance-2020-12-15-1.png

继承的定义

定义格式

继承格式:class derived-class: access-specifier base-class

Person是⽗类,也称作基类。Student是⼦类,也称作派⽣类 :
image.png

不同继承方式与继承的基类中访问限定符间的影响

  • 类的继承有三种类型:公有继承(public)、保护继承(protected)和私有继承(private)。
  • C++中的访问限定符有publicprotectedprivate,它们分别控制成员的可访问性。

具体的继承后访问权限如下:

类成员/继承方式public继承protected继承private继承
父类的public成员子类的public成员子类的protected成员子类的private成员
父类的protected成员子类的protected成员子类的protected成员子类的private成员
父类的private成员在子类中不可见在子类中不可见在子类中不可见

C++中的继承和访问控制总结

父类的private成员在子类中的访问限制

父类的private成员在子类中是不可见的。这意味着,虽然子类对象中仍然包含父类的private成员,但语法上子类无法访问这些成员,无论是在子类的内部还是外部。

protected成员的使用场景

父类的private成员在子类中不能被访问。如果需要父类成员在类外不能直接访问,但在子类中能够访问,那么应该将这些成员定义为protectedprotected成员限定符主要是为了解决继承中的访问控制问题而出现的。

成员访问方式总结

通过继承方式和父类成员的访问限定符,可以总结出父类的其他成员在子类中的访问方式:

  • public > protected > private

子类对父类成员的访问权限是取父类成员的访问限定符与继承方式的最小值。

继承方式的默认值

在使用关键字class时,默认的继承方式是private。而使用关键字struct时,默认的继承方式是public。尽管如此,最好显式地写出继承方式以提高代码的可读性。

class Base {
private:
    int privateMember;
protected:
    int protectedMember;
public:
    int publicMember;
};

class Derived : public Base {
    // 继承方式为public,访问权限如下:
    // privateMember:不可见
    // protectedMember:protected
    // publicMember:public
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
实际应用中的继承方式

在实际应用中,通常使用public继承,很少使用protectedprivate继承。原因在于protectedprivate继承的成员只能在子类内部使用,限制了代码的扩展性和可维护性。

示例代码

class Base {
private:
    int privateMember;
protected:
    int protectedMember;
public:
    int publicMember;
};

class PublicDerived : public Base {
    // privateMember:不可见
    // protectedMember:protected
    // publicMember:public
};

class ProtectedDerived : protected Base {
    // privateMember:不可见
    // protectedMember:protected
    // publicMember:protected
};

class PrivateDerived : private Base {
    // privateMember:不可见
    // protectedMember:private
    // publicMember:private
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

OOP中类之间的关系

“is a” 关系

“is a”关系:通过继承(Inheritance)来表示,表示类之间的层次关系。

“is a”关系通常表示继承(Inheritance)关系,也就是一个类是另一个类的特殊类型。比如,狗(Dog)是动物(Animal)的一种,我们可以通过继承来表示这种关系:

class Animal {
public:
    void makeSound() {
        std::cout << "Animal sound" << std::endl;
    }
};

class Dog : public Animal { // Dog is an Animal
public:
    void makeSound() {
        std::cout << "Bark" << std::endl;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

在这个例子中,Dog类继承自Animal类,这表明“狗是一种动物”(Dog is an Animal)。Dog类可以访问Animal类中的公共成员函数和变量。

“has a” 关系

“has a”关系:通过组合(Composition)或聚合(Aggregation)来表示,表示一个类拥有另一个类的实例。

“has a”关系通常表示组合(Composition)或聚合(Aggregation)关系,即一个类包含另一个类作为其成员。这种关系强调一个类拥有另一个类的实例。比如,汽车(Car)有一个引擎(Engine),可以用组合来表示这种关系:

class Engine {
public:
    void start() {
        std::cout << "Engine starts" << std::endl;
    }
};

class Car { // Car has an Engine
private:
    Engine engine;

public:
    void startCar() {
        engine.start();
        std::cout << "Car starts" << std::endl;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

在这个例子中,Car类包含一个Engine类的实例,这表明“汽车有一个引擎”(Car has an Engine)。Car类可以使用Engine类中的方法来实现其功能。

类模板的继承

类模板继承的基本语法

template<class T>
class Base {
    // 基类内容
};

template<class T>
class Derived : public Base<T> {
    // 派生类内容
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

访问控制和作用域解析

  • 访问控制:继承时,基类的成员的访问权限在派生类中依旧遵循C++的访问控制规则,即publicprotectedprivate
  • 作用域解析:在派生类中访问基类的成员时,需要使用作用域解析符来明确调用基类的成员:
template<class T>
class Derived : public Base<T> {
public:
    void foo() {
        Base<T>::bar(); // 调用基类的bar函数
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

名称查找和依赖名称

名称查找与依赖名称的问题主要源于模板的按需实例化机制和两阶段名称查找机制

两阶段名称查找
C++编译器对模板代码进行两次名称查找:

  1. 第一次名称查找:在模板定义时进行。编译器解析所有与模板参数无关的非依赖名称。
  2. 第二次名称查找:在模板实例化时进行。编译器解析依赖于模板参数的名称,即依赖名称。

依赖名称(Dependent Names)是指那些依赖于模板参数的名称。在第一次名称查找时,编译器无法确定这些名称的具体含义,只有在模板实例化时才能解析。

名称查找和作用域解析示例
template <typename T>
class Base {
public:
    void foo() {
        std::cout << "Base foo" << std::endl;
    }
};

template <typename T>
class Derived : public Base<T> {
public:
    void bar() {
        // 问题点:编译器在第一次名称查找时不知道foo()是从Base<T>继承的
        // 因为foo()是依赖于模板参数T的名称
        // foo(); // 这会导致编译错误

        // 解决方法1:使用this指针
        this->foo();

        // 解决方法2:使用作用域解析符
        Base<T>::foo();
    }
};

int main() {
    Derived<int> d;
    d.bar(); // 输出 "Base foo"
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29

编译器会在第一次名称查找时尝试解析foo()。但是由于foo()是依赖于模板参数T的成员函数,编译器无法确定foo()是从基类继承的。这是因为模板是按需实例化的,编译器在第一次查找时并不知道派生类实例化时会包含哪些基类成员。
在使用Derived<int> d;初始化的时候会对构造函数进行实例化并调用构造函数,但是当使用d.bar();时,如果在bar()中为foo();即会编译错误,原因就如上述,无法确定从基类继承。
所以解决如下:

  1. 使用**this**指针
void bar() {
    this->foo(); // 正确
}
  • 1
  • 2
  • 3

编译器会在第二阶段名称查找时解析foo(),并正确地找到基类中的foo()成员函数。这是因为this指针在类定义中总是已知的,并且它指向当前对象**(包括从基类继承的部分)**。

  1. 使用作用域解析符
void bar() {
    Base<T>::foo(); // 正确
}
  • 1
  • 2
  • 3

Base<T>::foo()明确指出了foo()来自基类Base<T>,消除了编译器的名称查找歧义。

父类和子类对象赋值兼容转换

子类对象可以赋值给父类对象、父类指针或父类引用

在公有继承中,子类对象可以赋值给父类对象、父类指针或父类引用(把⼦类中⽗类那部分切来赋值过去)。这种转换称为向上转换(upcasting)。

class Base {
public:
    void baseMethod() {
        std::cout << "Base method" << std::endl;
    }
};

class Derived : public Base {
public:
    void derivedMethod() {
        std::cout << "Derived method" << std::endl;
    }
};

int main() {
    Derived derivedObj;
    Base baseObj = derivedObj;  // 子类对象赋值给父类对象
    Base* basePtr = &derivedObj;  // 子类对象的地址赋值给父类指针
    Base& baseRef = derivedObj;  // 子类对象赋值给父类引用

    baseObj.baseMethod();  // 可以调用父类的方法
    basePtr->baseMethod();  // 可以通过父类指针调用父类的方法
    baseRef.baseMethod();  // 可以通过父类引用调用父类的方法

    // 以下调用都会导致编译错误,因为父类对象/指针/引用不能访问子类特有的方法
    // baseObj.derivedMethod();
    // basePtr->derivedMethod();
    // baseRef.derivedMethod();

    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

image.png

父类对象不能赋值给子类对象

父类对象不能赋值给子类对象,因为父类对象可能不包含子类对象所需的所有信息。这种转换会导致子类特有的数据丢失或变得不确定。

Base baseObj;
Derived derivedObj;

// 以下赋值会导致编译错误
// derivedObj = baseObj;
  • 1
  • 2
  • 3
  • 4
  • 5

父类的指针或引用可以通过强制类型转换赋值给子类的指针或引用

父类的指针或引用可以通过强制类型转换赋值给子类的指针或引用,但必须确保父类的指针实际上指向一个子类对象。这种转换称为向下转换(downcasting)

安全的类型转换

如果父类是多态类型,可以使用RTTI(运行时类型信息)中的dynamic_cast来进行安全转换。

Base* basePtr = new Derived;

Derived* derivedPtr = dynamic_cast<Derived*>(basePtr);
if (derivedPtr) {
    derivedPtr->derivedMethod();  // 安全转换后可以调用子类方法
} else {
    // 转换失败,basePtr并不指向Derived对象
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

强制类型转换

虽然可以使用static_cast进行强制转换,但这种转换在父类指针不指向子类对象时是危险的。

Base* basePtr = new Derived;

Derived* derivedPtr = static_cast<Derived*>(basePtr);
derivedPtr->derivedMethod();  // 需要确保basePtr实际指向Derived对象
  • 1
  • 2
  • 3
  • 4

总结

  • 子类对象可以赋值给父类对象、父类指针或父类引用,称为向上转换(upcasting),但会发生对象切片(slicing)。
  • 父类对象不能赋值给子类对象,因为父类对象缺乏子类特有的信息。
  • 父类指针或引用可以赋值给子类指针或引用,但必须确保指向实际的子类对象。可以使用dynamic_cast进行安全转换。

继承中的作⽤域

隐藏规则

  1. 在继承体系中⽗类和⼦类都有独⽴的作⽤域。
  2. ⼦类和⽗类中有同名成员,⼦类成员将屏蔽⽗类对同名成员的直接访问,这种情况叫隐藏。(在⼦
    类成员函数中,可以使⽤⽗类::⽗类成员显式访问)
  3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏
  4. 注意在实际中在继承体系⾥⾯最好不要定义同名的成员。

作⽤域相关知识考察

#include <iostream> // 不要忘记包含 iostream 头文件以使用 cout

class A {
public:
    void fun() {
        std::cout << "func()" << std::endl;
    }
};

class B : public A {
public:
    void fun(int i) {
        std::cout << "func(int i)" << i << std::endl;
    }
};

int main() {
    B b;
    b.fun(10); // 调用 B 类的 fun(int i)
    b.fun();   // 尝试调用 A 类的 fun(),但由于重载,实际上调用的是 B 类的 fun(int i) 
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  1. A和B类中的两个func构成什么关系?
  • 此时的A和B类构成的是隐藏的关系。
  1. 编译运⾏结果是什么?
  • 编译报错。(b.fun();

⼦类的默认成员函数

子类的构造函数

子类的构造函数必须调用父类的构造函数来初始化父类的那部分成员。如果父类没有默认构造函数,则必须在子类构造函数的初始化列表中显式调用父类的构造函数。

Student(const char* name, int num)
    : Person(name), _num(num) {
    cout << "Student()" << endl;
}
  • 1
  • 2
  • 3
  • 4

在初始化列表中可以注意初始化顺序,先声明的先初始化,所以先声明的父类会先定义。

子类的拷贝构造函数

子类的拷贝构造函数必须调用父类的拷贝构造函数来完成父类部分的拷贝初始化。

Student(const Student& s)
    : Person(s), _num(s._num) {
    cout << "Student(const Student& s)" << endl;
}
  • 1
  • 2
  • 3
  • 4

子类的赋值运算符

子类的赋值运算符必须调用父类的赋值运算符来完成父类部分的复制。需要注意的是,子类的赋值运算符会隐藏父类的赋值运算符,所以需要显式调用父类的赋值运算符。

Student& operator=(const Student& s) {
    cout << "Student& operator=(const Student& s)" << endl;
    if (this != &s) {
        // 构成隐藏,所以需要显式调用
        Person::operator=(s);
        _num = s._num;
    }
    return *this;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

子类的析构函数

不用再子类析构函数中显式调用父类的析构函数,子类的析构函数在被调用完成后,会自动调用父类的析构函数来清理父类成员。这样可以保证子类对象先清理子类成员再清理父类成员的顺序。

~Student() {
    cout << "~Student()" << endl;
}
  • 1
  • 2
  • 3

析构会按照后定义的先析,先调用子类析构,再调用父类析构。

多态中⼀些场景析构函数需要构成重写,重写的条件之⼀是函数名相同。那么编译器会对析构函数名进⾏特殊处理,处理成destructor(),所以⽗类析构函数不加virtual的情况下,⼦类析构函数和⽗类析构函数构成隐藏关系。

子类的赋值运算符重载

⼦类的operator=必须要调⽤⽗类的operator=完成⽗类的复制。需要注意的是⼦类的operator=隐
藏了⽗类的operator=,所以显⽰调⽤⽗类的operator=,需要指定⽗类作⽤域

student& operator=(const student& s)
{
	if (this != &s)
	{
		person::operator=(s);
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

不能被继承的类

有两种方法可以使类不可以被继承:

  1. ⽗类的构造函数私有,⼦类的构成必须调⽤⽗类的构造函数,但是⽗类的构成函数私有化以后,⼦类看不⻅就不能调⽤了,那么⼦类就⽆法实例化出对象。
  2. C++11新增了⼀个final关键字,final修改⽗类,⼦类就不能继承了。
class Base final
{
public:
void func5() { cout << "Base::func5" << endl; }

protected:
int a = 1;

private:
// C++98的⽅法
/*Base()
	{}*/
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

继承:友元&静态成员

继承与友元

友元关系不继承:
在C++中,友元关系是特定于某个类的。一个函数或类如果是父类的友元,它不会自动成为子类的友元。因此,父类的友元函数不能访问子类的私有成员和保护成员。同样地,如果你希望某个函数既是父类的友元,又是子类的友元,也可以在子类中声明该友元函数。

class Student;

class Person {
public:
    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;  // 尝试访问子类的保护成员,编译错误
}

int main() {
    Person p;
    Student s;
    Display(p, s);  // 编译报错:error C2248: “Student::_stuNum”: 无法访问 protected 成员
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

Display函数是Person类的友元,因此它可以访问Person类的保护成员 _name。但是,当它尝试访问Student类的保护成员_stuNum时,会产生编译错误。原因是友元关系不继承:Display函数虽然是Person的友元,但它不是Student的友元,所以不能访问Student的保护成员。
Display在子类中声明即可解决该问题:

class Student : public Person {
public:
    friend void Display(const Person& p, const Student& s); // 友元函数也要声明在子类中
protected:
    int _stuNum; // 学号
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

这样,Display函数就能同时访问PersonStudent的保护成员了。

继承与静态成员

在C++中,静态成员是属于类而不是某个特定对象的。⽗类定义了static静态成员,则整个继承体系⾥⾯只有⼀个这样的成员,这意味着即使类派生出了多个子类,它们都共享同一个静态成员实例。

class Person {
public:
    string _name;
    static int _count;
};

int Person::_count = 0; // 静态成员初始化

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

int main() {
    Person p;
    Student s;
    
    // 非静态成员_name地址不同,说明子类继承后,父子类对象各有一份
    cout << &p._name << endl;
    cout << &s._name << endl;

    // 静态成员_count地址相同,说明子类和父类共用同一个静态成员
    cout << &p._count << endl;
    cout << &s._count << endl;

    // 公有的情况下,父子类都可以访问静态成员
    cout << Person::_count << endl;
    cout << Student::_count << endl;

    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

运行结果:

0133FDE4
0133FDBC
0014E478
0014E478
0
0
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • _name是一个非静态成员,在PersonStudent对象中分别有独立的实例,所以它们的地址不同。
  • _count是一个静态成员,PersonStudent共享同一个静态成员实例,因此它们的地址相同。
  • 无论是通过父类还是子类,都可以访问静态成员。

多继承与菱形继承

继承模型

单继承

单继承是指一个子类只有一个直接父类。在这种情况下,子类继承父类的所有非私有成员,继承结构简单明了,访问成员变量也不存在歧义问题。

多继承

多继承是指一个子类有多个直接父类。C++支持多继承,这意味着一个子类可以从多个父类继承成员。在多继承中,C++规定在内存布局上,先继承的父类放在前面,后继承的父类放在后面,子类自己的成员放在最后。

class Person {
public:
    string _name; // 姓名
};

class Student : public Person {
protected:
    int _num; // 学号
};

class Teacher : public Person {
protected:
    int _id; // 职工编号
};

class Assistant : public Student, public Teacher {
protected:
    string _majorCourse; // 主修课程
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
多继承中指针偏移问题:

问题:下⾯说法正确的是()
A:p1p2p3 B:p1<p2<p3
C:p1==p3!=p2 D:p1!=p2!=p3

class Base2 { public: int _b2; };
class Base1 { public: int _b1; };
class Derive : public Base1, public Base2 { public: int _d; };

int main()
{
	Derive d;
	Base1* p1 = &d;
	Base2* p2 = &d;
	Derive* p3 = &d;
	return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

image.png
继承的时候会按照生命顺序来进行分配空间,也就是继承顺序。上述例子中先继承的是Base1,后继承的是Base2,所以按照规则栈会先为继承的Base1的信息进行开辟空间(栈向下开辟空间),然后再为Base2开辟空间,所以空间图如上图所示。

Base1* p1 = &d;
Base2* p2 = &d;
Derive* p3 = &d;
  • 1
  • 2
  • 3

以上是用了继承中基类对于派生类的向上转换(会进行类似切片操作,详见上文),所以此时的指向为下图:
image.png
此时的p1p3指向的是同一块地址,p2指向的之后分配的继承了Base2的空间。

正确答案为:p1 == p3 != p2

菱形继承

菱形继承是多继承中的一种特殊情况,发生在一个子类通过两个不同的路径继承自同一个基类时,形成菱形结构。

这种继承方式会带来数据冗余访问二义性的问题。

class Person {
public:
    string _name; // 姓名
};

class Student : public Person {
protected:
    int _num; // 学号
};

class Teacher : public Person {
protected:
    int _id; // 职工编号
};

class Assistant : public Student, public Teacher {
protected:
    string _majorCourse; // 主修课程
};

int main() {
    Assistant a;
    a._name = "peter"; // 编译报错:error C2385: 对“_name”的访问不明确
    a.Student::_name = "xxx"; // 需要显式指定访问哪个父类的成员
    a.Teacher::_name = "yyy"; // 但是数据冗余问题无法解决
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 数据冗余:在Assistant类中,由于StudentTeacher都继承了Person,所以Assistant中会有两份Person的拷贝。换句话说,Assistant类中有两份_name成员,这样会导致内存上的浪费。
  • 访问二义性:当你在Assistant类中访问_name时,编译器无法确定你想访问的是从Student继承过来的_name,还是从Teacher继承过来的_name,因此会报错。
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
  • 1
  • 2

可以通过显式的指定访问的是哪个父类的成员,或者使用虚继承,即可解决当前问题。

不推荐使用菱形继承

虚继承

虚继承(virtual inheritance)是C++中的一种特殊继承机制,用来解决多继承中的菱形继承问题,特别是避免数据冗余和访问二义性。
在多继承中,如果一个子类通过不同的路径从同一个基类继承,那么就会形成菱形继承。菱形继承会导致子类中存在多个基类实例,从而产生数据冗余和访问二义性的问题。虚继承通过修改基类在继承链中的存储方式,使得即使存在多重继承,所有子类中只会存在一个基类的实例,从而避免数据冗余和访问二义性。

class Person {
public:
    string _name; // 姓名
};

// 使用虚继承Person类
class Student : virtual public Person {
protected:
    int _num; // 学号
};

// 使用虚继承Person类
class Teacher : virtual public Person {
protected:
    int _id; // 职工编号
};

class Assistant : public Student, public Teacher {
protected:
    string _majorCourse; // 主修课程
};

int main() {
    // 使用虚继承,可以解决数据冗余和二义性
    Assistant a;
    a._name = "peter";
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

虚继承的原理

当一个类通过virtual关键字虚继承一个基类时,编译器确保在多继承链中该基类只会有一个实例。在上述示例中,StudentTeacher都虚继承自Person,因此在Assistant类中,Person的实例只会有一个。

虚继承的内存分布

在普通继承中,每个子类都会在其对象中包含父类的成员。但在虚继承中,编译器通过在子类中存储一个指向基类的指针来避免冗余。这个指针指向了唯一的基类实例,确保整个继承体系中只存在一个基类实例。

注意事项

  • 构造函数调用顺序:因为虚继承之后只存在一个实例,所以当使用虚继承时,基类的构造函数在最派生类(如Assistant)的构造函数中被调用,而不是在虚继承的直接派生类(如StudentTeacher)中。派生类的构造函数负责初始化基类的那部分。
class Assistant : public Student, public Teacher {
public:
    Assistant(const string& name) : Person(name), Student(), Teacher() {}
};
  • 1
  • 2
  • 3
  • 4

在这个例子中,由于Person是通过虚继承的,所以必须在Assistant的构造函数中显式地调用Person的构造函数来初始化_name

  • 虚继承的时候注意:进行虚继承的是那个产生数据冗余和二义性的公共基类的子类。

image.png

继承和组合

继承(Inheritance)和组合(Composition)是面向对象编程中两种重要的代码复用手段。它们在实际开发中各有优势和适用场景。

继承(Inheritance)

继承是一种is-a关系,表示子类是父类的一种特殊类型。通过继承,子类可以复用父类的属性和方法。
特点:

  • 代码复用:子类自动继承父类的所有成员变量和成员函数。
  • 多态性:子类可以重写父类的虚函数,提供不同的实现。
  • 强耦合:子类与父类之间有很强的依赖关系,父类的修改可能影响到所有子类。
class Car {
public:
    void Start() {
        cout << "Car starts." << endl;
    }
};

class BMW : public Car {
public:
    void Drive() {
        cout << "BMW drives fast." << endl;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

在上面的代码中,BMW类继承了Car类,所以BMW类可以直接使用Car类中的Start方法。

组合(Composition)

组合是一种has-a关系,表示一个类拥有另一个类的实例。这种方式通过将一个对象作为另一个对象的成员变量来实现代码复用。
组合的特点:

  • 松耦合:组合关系中的类是独立的,一个类的修改不会影响到其他类。
  • 黑箱复用:组合对象的内部实现对外部不可见,只暴露必要的接口。
  • 灵活性:通过组合,可以动态地创建更复杂的对象结构。
class Engine {
public:
    void Start() {
        cout << "Engine starts." << endl;
    }
};

class Car {
private:
    Engine engine; // Car has an Engine
public:
    void Start() {
        engine.Start();
        cout << "Car starts." << endl;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

在上面的代码中,Car类包含了一个Engine类的实例,Car类通过组合来复用Engine类的功能。

继承与组合的比较

  • 复用性:继承可以直接复用父类的实现,组合则通过使用已有类的实例来复用功能。
  • 耦合度:继承会导致子类与父类的紧密耦合,组合则保持类之间的独立性。
  • 可维护性:由于继承的强耦合性,父类的修改可能影响子类,从而降低了代码的可维护性。组合则更容易维护,因为它遵循单一职责原则,每个类只负责自己的部分。
  • 扩展性:组合更容易扩展,因为可以通过组合不同的类来创建新的功能,而继承则在层次结构上有更多的限制。

继承与组合的使用原则

  • 优先使用组合:在设计类结构时,优先考虑使用组合,因为它可以减少耦合,提高代码的灵活性和可维护性。
  • 适当使用继承:当子类确实是父类的一种类型(即符合is-a关系)时,可以考虑使用继承。继承的优势在于实现多态性,但过度使用继承可能导致复杂的继承层次结构和高耦合。

实例分析

示例 1:组合(has-a 关系)

class Tire {
protected:
    string _brand = "Michelin"; // 品牌
    size_t _size = 17; // 尺寸
};

class Car {
protected:
    string _colour = "白色"; // 颜色
    string _num = "陕ABIT00"; // 车牌号
    Tire _t1, _t2, _t3, _t4; // 轮胎组合
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

在这里,Car类通过组合了四个Tire类的实例来实现车轮的功能,这就是一个典型的has-a关系。

示例 2:继承(is-a 关系)

class Car {
protected:
    string _colour = "白色"; // 颜色
    string _num = "陕ABIT00"; // 车牌号
};

class BMW : public Car {
public:
    void Drive() { cout << "好开-操控" << endl; }
};

class Benz : public Car {
public:
    void Drive() { cout << "好坐-舒适" << endl; }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

组合与继承的实际应用

  • 继承主要用于需要复用父类的代码或实现多态性的时候。
  • 组合主要用于需要动态组合功能、减少类之间的耦合以及增强代码的灵活性时。

综合示例

在一些场景中,组合和继承可能会混合使用,例如在一个stack类中,既可以使用组合来包含一个vector对象,也可以通过继承来扩展vector类的功能。
**继承方式: **

template<class T>
class stack : public vector<T> {
    // stack继承自vector
};
  • 1
  • 2
  • 3
  • 4

**组合方式: **

template<class T>
class stack {
public:
    vector<T> _v; // 通过组合方式来包含一个vector对象
};
  • 1
  • 2
  • 3
  • 4
  • 5

在实际设计时,建议优先考虑组合,这样可以保持类的封装性和独立性,从而提高代码的可维护性。

image.png

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小惠珠哦/article/detail/1007008
推荐阅读
相关标签
  

闽ICP备14008679号