【C++】深层次了解继承,从基础概念到复杂菱形继承问题(文章结尾有菱形继承常见面试题)

349 阅读5分钟

1.继承的概念及定义

继承的概念

继承是面向对象设计使代码可以复用的重要手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生的类,称为派生类。

继承的概念并不是固定的,只要能够通过自己的语言组织起来,再结合一些常见实例解释就可以了。

class Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
protected:
	string _name = "peter"; // 姓名
	int _age = 18;			//年龄
};

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


class Teacher : public Person
{
protected:
	int _jobid; // 工号
};

int main()
{
	Student s;
	Teacher t;
	s.Print();
	t.Print();
	return 0;
}

继承后父类的Person的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了

Student和Teacher复用了Person的成员; 下面我们使用visual studio监视窗口查看Student和Teacher对象,可以看到变量的复用,调用Print可以看出成员函数的复用。

继承的定义

定义格式

以学生(Student)类为例,继承Person类;

Person类是父类,也称作基类; Student是子类,也称作派生类。

三种继承方式和三种访问限定符

子类的三种继承方式:public、protected、private

父类中三种访问限定符:public、protected、private

下面这张表是继承方式、访问限定符的变化对子类成员的影响:

示例:

(1).父类中的public成员,公有继承

class Person
{
public:
    void show()
    {
        cout << "name:" << name << " " << "age:" << age << endl;
    }
    string _name;
    int _age;
};
class Student : public Person
{};
int main()
{
    Student s1;
    s1.name = "阿飞";
    s1.age = 19;
    s1.show();
    return 0;
}

Student中没有任何成员,只有从Person类中继承下来的name和age。

(2).父类的protected成员,公有继承

同样使用Person类,只是把成员变量name和age改为了protected:

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

注意:子类继承之后成员属性为protected,不能在类外进行访问。

protected属性的成员在类内是可以访问的,可以在类内设置接口进行访问。

class Student : public Person
{
public:
    void Set(string m_name, int m_age)
    {
        name = m_name;
        age = m_age;
    }
};
int main()
{
    Student s1;
    s1.Set("阿飞", 19);
    return 0;
}

类外无法访问类内的protected/private成员,但是可以设置公有的接口对类内的protected/private成员进行访问。

(3)父类的private成员,公有继承

上面提到,父类的private成员在子类中是不可见的,那么这个不可见是什么含义呢?

class Person
{
public:
    void show()
    {
        cout << "name:" << name << " " << "age:" << age << endl;
    }
private:
    string name;
    int age;
};

在子类Student中设置公有的属性去访问父类中的private是否可行?

不可见: 子类继承父类的成员在类内和类外都无法进行访问。

一般的话,我们不会设置父类的成员为private,除非不想被子类继承的成员。

总结:

  1. 父类中的private成员在子类中无论以什么方式继承都是不可见的(语法上限制子类对象不管是在类内还是类外都不能访问)
  2. 如果子类成员不想在类外被访问,但需要在类内访问的,父类中就可以定义为protected。
  3. 父类其他成员在子类中的访问方式 为继承方式和访问限定符中权限小的一个(public > protected > private)
  4. 使用class定义类时默认的继承方式是private,使用struct默认继承方式为public,不过最好显示写出继承方式。
  5. 实际运用中一般使用public继承,很少用到protected和private继承。

2.父类和子类对象赋值转换

  • 子类对象可以赋值给父类对象/父类指针/父类的引用,这种也叫切片或者切割;寓意把子类中父类那部分切来赋值过去
  • 父类对象不能赋值给子类对象
  • 父类的指针或者引用可以通过强制类型转换赋值给子类的指针或者引用,但是必须是父类的指针指向子类对象时才安全
class Person
{
protected:
	string _name; // 姓名
	string _sex;
	int _age; // 年龄
};

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

void Test()
{
	Student sobj;
	// 1.子类对象可以赋值给父类对象/指针/引用
	Person pobj = sobj;
	Person* pp = &sobj;
	Person& rp = sobj;

	//2.基类对象不能赋值给派生类对象
	//sobj = pobj;

	// 3.父类的指针可以通过强制类型转换赋值给子类的指针
	pp = &sobj;
	Student* ps1 = (Student*)pp; // 这种情况转换时可以的。
	ps1->_No = 10;

	pp = &pobj;
	Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问题
	ps2->_No = 10;

    //父类的引用赋值给子类的引用
    rp = sobj;
	Student& ps3 = (Student&)rp;
}

3.继承中的作用域

每一个变量都有其对应的作用域,类中也有属于自己的类域;而且不同的类有不同的类域;

父类和子类中的成员在不同的类域中。

  1. 在继承体系中父类和子类都有独立的作用域
  2. 子类和父类中有同名成员,子类成员将屏蔽父类中的成员, 对子类中同名成员直接访问; 这种情况叫隐藏, 也叫重定义(在子类成员函数中,可以使用 基类::基类成员 直接访问)
  3. 需要注意的是,如果是成员函数的隐藏,只需要函数名相同即可
  4. 注意在实际的继承体系中,尽量不要出现同名的成员

示例1:

// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆
class Person
{
protected :
    string _name = "小李子"; // 姓名
    int _num = 111;   // 身份证号
};
class Student : public Person
{
public:
    void Print()
    {
    cout<<" 姓名:"<<_name<< endl;
    cout<<" 身份证号:"<<Person::_num<< endl;
    cout<<" 学号:"<<_num<<endl;
    }
protected:
    int _num = 999; // 学号
};
void Test()
{
    Student s1;
    s1.Print();
};

示例2:

// B中的fun和A中的fun不是构成重载,因为不是在同一作用域
// B中的fun和A中的fun构成隐藏,成员函数满足函数名相同就构成隐藏。
class A
{
public:
    void fun()
    {
        cout << "func()" << endl;
    }
};
class B : public A
{
public:
    void fun(int i)
    {
        A::fun();
        cout << "func(int i)->" <<i<<endl;
    }
};
void Test()
{
    B b;
    b.fun(10);
};

注意:构成隐藏的成员函数,在进行调用时一定要满足参数的匹配,否则会出现调用错误。

4.子类的默认成员函数

6个默认成员函数,“默认”的意思就是我们不写,编译器会自动生成;那么在子类中,这几个成员函数是如何生成的呢?

  • 子类的构造函数必须调用父类的构造函数初始化父类的那一部分成员,如果父类没有默认的构造函数,则必须在子类构造函数的初始化列表阶段显示调用; 子类的部分调用子类的构造函数(内置类型不做处理,自定义类型调用自定义类型的默认构造)

下面的代码对应的就是父类中没有默认构造,只有有参构造时的情况:

Student(const char* name, int num)
		: Person(name)
		, _num(num)
{
    cout << "Student()" << endl;
}
  • 子类的拷贝构造必须调用父类的拷贝构造完成父类成员的初始化,子类成员调用子类构造函数完成初始化
  • 子类operator== :调用父类的operator==完成父类成员的赋值,子类成员调用子类的operator完成赋值
  • 子类的析构函数调用父类的析构清理父类成员,调用子类的析构函数清理子类成员
  • 子类对象初始化先调用父类构造,再调用子类构造;清理先调用子类析构,再调用父类析构
class Person
{
public:
	Person(const char* name = "peter")
		: _name(name)
	{
		cout << "Person()" << endl;
	}

	Person(const Person& p)
		: _name(p._name)
	{
		cout << "Person(const Person& p)" << endl;
	}

	Person& operator=(const Person& p)
	{
		cout << "Person operator=(const Person& p)" << endl;
		if (this != &p)
			_name = p._name;

		return *this;
	}

	~Person()
	{
		cout << "~Person()" << endl;
	}
protected:
	string _name; // 姓名
};


class Student : public Person
{
public:
	Student(const char* name, int num)
		: Person(name)
		, _num(num)
	{
		cout << "Student()" << endl;
	}

	Student(const Student& s)
		: Person(s)
		, _num(s._num)
	{
		cout << "Student(const Student& s)" << endl;
	}

	Student& operator = (const Student& s)
	{
		cout << "Student& operator= (const Student& s)" << endl;
		if (this != &s)
		{
			Person::operator =(s);
			_num = s._num;
		}
		return *this;
	}

	~Student()
	{
		cout << "~Student()" << endl;
	}
protected:
	int _num; //学号
};


void Test()
{
	Student s1("jack", 18);
}

5.继承与友元

友元函数的概念

某些虽然不是类中的成员却能够访问类的所有成员(包括protected和private)的函数称为友元函数

class Person
{
	friend void Display(const Person& p);//Display是Person类的友元函数
public:
    void SetName(const string& name)
    {
        _name = name;
    }
protected:
	string _name; // 姓名
};
void Display(const Person& p)
{
    cout << p._name << endl;
}
int main()
{
    Person p;
    p.SetName("阿飞");
    Display(p);
    return 0;
}


继承中的友元函数

友元关系不能继承,也就是说父类的友元函数不能访问子类中的protected和private成员。

class Student;
class Person
{
public:
	friend void Display1(const Person& p);
	friend void Display2(const Student& s);
protected:
	string _name= "阿飞"; // 姓名
};

class Student : public Person
{
protected:
	int _stuNum = 101; // 学号
};

void Display1(const Person& p)
{
	cout << p._name << endl;
}

void Display2(const Student& s)
{
	cout << s._stuNum << endl;

}
int main()
{
	Person p;
	Display1(p); //正常运行

	Student s;
	Display2(s); //编译报错
	return 0;
}

注意:如果需要访问子类中的protected/private成员,可以把函数声明为子类的友元。

class Student : public Person
{
	friend void Display(const Person& p, const Student& s);
protected:
	int _stuNum; 
};

6.继承与静态成员

一般成员在子类和父类中都是单独的一份,而静态成员在父类和子类中是同一份。

示例:

class Person
{
public:
	static int _count; 
};
int Person::_count = 0;	//static类内声明,类外初始化
class Student : public Person
{};

int main()
{
    Person p;
    Student s;
    cout << s._count << endl;
    Person::_count++;
    cout << s._count << endl;
    //打印一下父类和子类中静态成员的地址
    cout << &Person::_count << endl;
    cout << &Student::_count << endl;
    return 0;
}

注意:子类对象和父类对象中的静态成员_count是同一份,改变父类对象中的_count,子类对象中的_count也会随之改变。

7.菱形继承和菱形虚拟继承

菱形继承的概念

在提出菱形继承的概念之前,首先看一下单继承和多继承;

单继承:只有一个直接父类。

多继承:有两个或者两个以上的直接父类。

菱形继承其实是多继承的一种特殊情况:

菱形继承存在的问题:

如图,可以看出菱形继承有数据冗余二义性的问题(在Assistant对象中Person成员有两份)

示例:

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; // 主修课程
};

void Test()
{
	// 这样会有二义性无法明确知道访问的是哪一个
	Assistant a;
	//a._name = "peter";//error C2385: 对“_name”的访问不明确
	// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
	a.Student::_name = "xxx";
	a.Teacher::_name = "yyy";
}

虚拟继承

虚拟继承可以解决菱形继承的二义性和数据冗余问题,但是在其他地方不要随便使用虚拟继承。

class Person
{
public :
	string _name ; // 姓名
};
class Student : virtual public Person
{
protected :
	int _num ; //学号
};
class Teacher : virtual public Person
{
protected :
	int _id ; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected :
	string _majorCourse ; // 主修课程
};
void Test ()
{
    Assistant a ;
    a._name = "peter";
}

为了研究虚拟继承的原理,我们给出一个简化的菱形继承模型,再借助visual studio的调试内存窗口来观察一下:

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::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;
	return 0;
}

非虚拟继承时,继承时未使用virtual

继承体系中B中和C中都有一份A,导致数据冗余;下面看一下虚拟继承时如何解决这个问题的。

虚拟继承,继承时加上virtual关键字

下图是虚拟继承的内存对象成员模型:_a同时属于B和C,那么B和C如何去找公共的_a呢?

这里通过了B和C中的两个指针,指向的一张表;这两个指针叫虚基表指针,这两个表叫虚基表;虚基表中存的是偏移量,通过偏移量可以找到A中的成员_a。

虚拟继承中的切片情况:

int main()
{
    D d;
    B b = d;
    B* pb = &d;
    pb->_a = 10;
    return 0;
}

由于_a在B和C中是公共的,在切片时是不能直接放在B或者C中的,所以在这里访问_a还是需要虚基表指针来找到偏移量,然后偏移量加上当前对象的地址,来访问_a。

8.继承的总结和反思

  1. C++语法虽然支持多继承,但是一般是不建议设计出多继承的,因为多继承问题比较复杂,而且多继承问题可能引发菱形继承的问题,导致我们所研究的问题更复杂。
  2. 多继承可以认识是C++的缺陷之一,很多的语言是不支持多继承的,如我们所熟悉的Java。

继承和组合

  • public继承是一种is-a的关系;例如 Student is-a Person
  • 组合是一种has-a的关系;例如 车has-a轮胎(a只是一个量词,实际不一定是一个)
  • 如果两个物体之间的关系既可以是is-a,也可以使has-a,那么优先使用has-a的组合,在编程中,我们追求的是一种“高内聚,低耦合”:因为组合中耦合度减低
  • 当继承和组合都可以解决问题时,优先使用组合

9.笔试面试题

1.什么是菱形继承?菱形继承的问题时什么?

两个子类同时继承一个父类,而且又有一个子类同时继承这两个类,这就是我们通常所说的菱形继承;

菱形继承的问题在于数据冗余和二义性。

2.什么是菱形虚拟继承?如何解决数据冗余和二义性的?

两个子类同样同时继承一个父类,但是不同于菱形继承的是,继承时是一种虚拟继承(使用virtual关键字完成);同样有一个子类继承同时继承这两个类。

继承最开始的那个父类,它的成员在它的子类中不会存储多份,而是只存储了一份,在它的子类中各有一个指针(这个指针也叫虚基表指针),这个指针指向一个虚基表,虚基表中存储的有父类成员的偏移量,通过这个偏移量加上自身对象的地址,找到的就是父类中的那个成员;因为这里子类继承之后父类的成员只有一份,所以就解决了数据冗余和二义性。

3.继承和组合的区别?什么时候用继承?什么时候用组合?

继承是一种is a的关系,像我们常说的Person和Student类,Student is a Person,而组合是一种has a的关系,像一辆汽车has a 油箱,这里的has a 的a是一个量词,并不是一个,像一辆小汽车是可以有四个轮胎的。

继承中父类的protected成员在子类中public继承在类内是可以访问到的,增加了代码的耦合性;而组合中是类中包含这个自定义类型,像一个car类中包含这个轮胎类,轮胎类中有其他的一些成员,这种耦合度很低;

我们的程序设计追求的是一种“高内聚,低耦合”,所以如果继承和组合都可以使用的时候,要尽量的使用组合,当组合不能实现时,再考虑使用继承来完成。