C++学习笔记——C++中的代码重用

发布时间 2023-12-03 01:17:17作者: owmt

  C++的一个主要目标是促进代码重用。除了公有继承之外,还可以通过包含、私有继承、保护继承实现。公有继承实现 is-a 关系,其余实现 has-a 关系。通过多重继承能够使用两个或更多的基类派生出新的类,将基类的功能组合在一起。

包含对象成员的类

  包含是C++实现代码重用的技术之一,包含指的是创建一个包含其他类对象的类。如下面的Student类所示,Student类包含 string 类对象和 valarray<double> 类对象。Student类将name和scores对象声明为私有的,意味着Student类的成员函数可以通过string和valarray<double>类的公有接口来访问和修改name和scores对象。但在Student类外面不能这么做。即Student类获得了其成员对象的实现,但是没有继承接口。

  使用公有继承时,类可以继承接口,可能还继承实现(纯虚函数提供接口,不提供实现),获得接口也是is-a关系的组成部分。使用包含,类可以获得实现,但是不能获得接口。

  初始化包含的对象使用成员名而不是类名,被包含对象的接口不是公有的,但可以在类方法中使用,例如可以用Student类的Student方法访问name和scores类对象的方法。

 1 #pragma once
 2 
 3 #include<iostream>
 4 #include<string>
 5 #include<valarray>
 6 
 7 class Student {
 8 private:
 9     typedef std::valarray<double> ArrayDb;
10     std::string name;
11     ArrayDb scores;
12     std::ostream& arrOut(std::ostream& os) const;
13 public:
14     Student() : name("Null"), scores() {}
15     explicit Student(const std::string& s)
16         : name(s), scores() {}
17     explicit Student(int n)
18         : name("Null"), scores(n) {}            //使用explicit防止单参数构造函数的隐式转换
19     Student(const std::string& s, int n)        //初始化列表使用成员名来初始化,因为初始化的是成员对象,不是继承的对象(初始化继承的对象要使用类名)
20         : name(s), scores(n) {}                 //初始化的顺序是按声明时的顺序,不是成员初始化列表中的顺序
21     Student(const std::string& s, const ArrayDb& a)
22         : name(s), scores(a) {}
23     Student(const char* str, const double* pd, int n)
24         : name(str), scores(pd, n) {}
25     ~Student(){}
26     double Average() const;
27     const std::string& Name() const;
28     double& operator[](int i) {
29         return scores[i];
30     }
31     double operator[](int i) const {
32         return scores[i];
33     }
34 
35     friend std::istream& operator>>(std::istream& is, Student& st);
36     friend std::istream& getline(std::istream& is, Student& st);
37     friend std::ostream& operator<<(std::ostream& os, const Student& st);
38 };

 

 私有继承

  私有继承也是实现 has-a 关系的途径之一,使用私有继承,基类中的公有成员和保护成员都成为派生类的私有成员,基类方法不会成为派生对象公有接口的一部分。包含将类对象作为一个命名的成员对象添加到类中,与包含不同的是私有继承将对象作为一个为未命名的继承对象添加至类中。术语 子对象 表示通过继承或包含添加的对象。

  如下所示,通过私有继承为Student类提供了两个无名称的子对象成员,这是私有继承和包含的第一个区别。私有继承的特性和包含相同:获得实现,但不获得接口。

 

1 class Student : private std::string, private std::valarray<double> { //多重私有继承
2 public:
3     ...
4 };

 

 

 

1、初始化基类组件

  不同于包含,隐式地继承组件与使用包含的代码不同。包含可以使用显式地成员对象名来描述对象,而隐式继承组件需要使用类名来表标识,这是私有继承和包含的第二个区别。

1 Student(const char* str, const double* pd, int n)
2     : name(str), scores(pd, n) {}   //use object names for containment
3 
4 Student(const char* str, const double* pd, int n)
5     : std::string(str), std::valarray(pd, n) {} // use class names for inheritance

2、访问基类的方法

   使用私有继承,只能在派生类的方法中使用基类方法。用 类名+作用域解析运算符 来调用基类的方法,包含显式使用对象名来调用方法。

1 double Student::Average() const{
2     if(std::valarray::size() > 0)
3         return std::valarray::sum() / std::valarray::size():
4     else 
5         return 0;
6 }

3、访问基类对象

  可以使用作用域解析运算符访问基类的方法,但使用私有继承时,继承的组件对象是没有名称的,所以要使用 强制类型转换 来访问内部的基类对象。this指针指向用来调用类方法的对象,*this为用来调用类方法的对象。同时使用强制类型转换创建一个引用,避免调用构造函数创建新的对象。

1 const string& Student::Name() const {
2     return (const string&) *this;
3 }

4、访问基类的友元函数

  友元不属于类,所有不能通过类名显式地限定函数名,只能通过显式地转换为基类来调用基类的友元函数。假设第 6 行代码中的 plato 为 Student 对象,该语句会调用上面的函数,stu 指向 plato。在私有继承中,在不进行显式类型转换的情况下,不能将派生类的引用或指针赋给基类引用或指针,因为不使用类型转换可能导致递归调用;另一个原因是如果类使用多重继承,编译器无法确定要转换成哪个基类。

  os << (const string&) st << endl; 这行代码显式地将 st 转化为 string 对象引用,从而调用函数 operator<<(ostream&, const string&)。

1 ostream& operator<<(ostream& os, const Student& st) {
2     os << (const string&) st << endl;    //如果去掉(const string&)强制类型转化,会导致递归调用
3     ...
4 }
5 
6 cout << plato;

5、包含还是继承的选择

  可以使用包含和私有继承建立 has-a 关系。通常更倾向于使用包含,类声明中标识被包含类的显式命名对象,代码可以通过名称引用这些对象。包含能包括多个同类的子对象,而继承只能有一个。

  继承使关系较抽象,且多重继承时,需要额外处理(继承多个包含同名方法的独立的类或共享祖先的独立基类)。但是私有继承也有自己的特性,例如可以重新定义虚函数(包含类不能)、可以访问原有类的保护成员。

   Tips:通常用包含建立 has-a 关系,若新类需要访问原有类的保护成员,或需要重新定义虚函数,则使用私有继承 

 

保护继承

  保护继承是私有继承的变体,使用保护继承时,基类的公有成员和保护成员都成为派生类的保护成员。与私有继承不同之处在于,私有继承的第三代类不能使用基类的接口。而使用保护继承时,基类的公有方法在第二代类中变成保护成员,第三代类仍可以访问基类的公有接口。

1 class Studnet : protected std::string, protected std::valarray<dobule>{
2     ...
3 };

 

使用using重新定义访问权限

  使用保护派生或私有派生时,若想在类外面使用基类的方法,方法一是定义一个使用该基类方法的派生类方法。另一个是将函数调用包装在另一个函数调用中,使用 using 声明(类似名称空间)。 using声明只能使用成员名——没有圆括号、函数特征标和返回类型,using声明只适合于继承,不适用于包含。

1 class Student : private std::string, private std::valarray<double>{
2     ...
3 public:
4     using std::valarray<double>::min;
5     using std::valarray<double>::max;
6 };

 

14.3 多重继承

  多重继承(MI)指的是有多个直接继承的类。公有MI表示 is-a 关系,私有MI和保护MI表示 has-a 关系。继承时需要使用关键字指出使用哪种继承方式,否则采用默认继承。

  MI需要考虑的问题:

    1、从两个不同的基类继承同名方法;

    2、从两个或更多相关基类继承同一个类的多个实例。

 14.3.1 Worker的数量

   假设从Singer和Waiter派生出SingerWaiter类,由于Waiter和Singer都继承了一个Worker组件,所以SingingWaiter将包含两个Worker组件。这将会出现二义性。如16、17行代码所示。pw指针此时不知道要指ed中的哪个Worker组件(ed包含两个Worker对象),可以使用类型转换来指向对象。

 

 1 class Worker {...};
 2 class Waiter : public Worker{...};
 3 class Singer : public Worker{...};
 4 class SingingWaiter : public Singer, public Waiter{...};
 5 
 6 SingingWaiter ed;
 7 Worker* pw = &ed; // ambiguous
 8 
 9 Worker* pw1 = (Waiter*) &ed; //use Worker in Waiter
10 Worker* pw2 = (Singer*) &ed; //use Worker in Singer

  这让使用基类指针引用不同的对象(多态性)复杂化,同时也存在一个问题:为什么需要Worker对象的两个拷贝? C++引入一种新技术——虚基类,来解决这个问题。

  1、虚基类

    虚基类使得从多个类(它们基类相同)派生出的对象只继承一个基类对象。在类声明中,使用关键字virtual,将Worker作为Singer和Waiter的虚基类,这样SinggerWaiter类中只包含Worker对象的一个副本,本质上继承的Singer和Witer对象共享一个Worker对象。

 

1 class Worker {...};
2 class Waiter : virtual public Worker{...};
3 class Singer : virtual public Worker{...};
4 class SingingWaiter : public Singer, public Waiter{...};

  2、新的构造函数规则

    使用虚基类时,类的构造函数需要采用新的方法。对于非虚基类,基类构造函数是唯一可以出现在初始化列表中的构造函数,它将信息传递给基类,初始化基类部分。例如派生类只能调用基类的构造函数,基类只能调用基类的基类的构造函数(如果有),并将所需的信息一层一层的传递上去。但如果Woker是虚基类,则这种自动传递将不起作用。

     对于下述代码,如果允许自动传递信息,将有两条途径可以传递信息,为了避免冲突,C++在基类是虚的情况下,禁止信息通过中间类自动传递给基类。

1 SingingWaiter(const Worker& wk, int p = 0, int v = Singer::other)
2     :Waiter(wk, p), Singer(wk, v) {} //fawed

     上述构造函数只初始化成员,wk参数的信息不会传给子对象Waiter和Singer。但是编译器必须在构造派生对象之前构造基类对象的组件,因此这种情况下,编译器将使用Worker的默认构造函数,如果不想使用基类的默认构造函数,则需要显式调用所需的基类构造函数。

 

1 SingingWaiter(const Worker& wk, int p = 0, int v = Singer::other)
2     :Worker(wk), Waiter(wk, p), Singer(wk, v) {}

 

    注:如果类有间接虚基类,且不想使用该虚基类的默认构造函数,那么需要显式的调用该虚基类的某个构造函数

 

14.3.2 哪个方法?

  如果派生类对象没有新的数据成员,对于基类中的某个方法没有重新定义,而是试图直接调用继承的某个方法,多重继承情况下这将导致二义性。对于单继承来说,如果没有重新定义函数,则将使用最近祖先中的定义。而在多重继承下,每个直接可能都有一个相同的函数,导致二义性。解决方法一个是使用作用域解析运算符,另一个是重新定义函数,使用模块化的方式。

  其他有关MI的问题:

  1、混合使用虚基类和非虚基类

    如果基类是虚基类,派生类包含基类的一个子对象,如果基类不是虚基类,派生类包含多个子对象。通过多条虚途径和非虚途径继承某个特定的基类时,该类将包含一个表示所有虚途径的基类子对象和分别表示各条非虚途径的多个基类子对象。如下面代码所示,类M包含3个基类子对象。

 

1 class B {...};
2 class C : public virtual B {...};
3 class D : public virtual B {...};
4 class X : public B {...};
5 class Y : public B {...};
6 class M : public C,public D,public X, public Y {...};

 

  2、虚基类和支配

    使用虚基类会改变C++解析二义性的方式。如果使用虚基类,没有用类名对方法进行限定也不一定会导致二义性——派生类中的名称优先于直接或间接祖先类中的相同名称。

 

    类C中的q()定义优先于类B中的q()定义,因为类C是从类B派生而来,因此F中可以直接使用q()表示C::q()。但是在F中使用非限定的omg()将导致二义性,即使E中的omg方法是私有的。同样,即使C::q()是私有的,也优先于B::q()。这种情况下,可以在F中调用B::q(),但如果不限定q(),意味着要调用不可访问的C::q()。

 1 class B {
 2 public:
 3     short q();
 4     ...
 5 };
 6 
 7 class C : public virtual B {
 8 public:
 9     long q();
10     int omg();
11     ...
12 };
13 
14 class D : public C {...};
15 
16 class E : public virtual B {
17 private:
18     int omg();
19     ...
20 };
21 
22 class F : public D, public E {...};

 

 14.3.3 MI小结

  不使用虚基类的MI不会引入新的规则,如果一个类从两个不同的类那里继承两个同名的成员,需要在派生类中使用类限定符区分,否则编译器将指出二义性。

  如果一个类通过多种途径继承了一个非虚基类,则该类从每种途径分别继承非虚基类的一个实例。

  如果使用虚基类的MI,从虚基类的一个或多个实例派生出来的类只继承一个基类对象。实现这种特性需要满足的要求: 1、有间接虚基类的派生类包含直接调用间接基类构造函数的构造函数(使用虚基类时,构造函数的自动传递不起作用),这对间接非虚基类是非法的。 2、通过优先规则解决名称的二义性。

  MI会增加编程的复杂度,这是主要由于派生类通过多条途径继承同一个基类引起的。避免这种情况后,在必要时对继承的名称进行限定。 

 

 14.4 类模板

   继承(公有、私有、保护)和包含并不是总能满足重用代码的需要。

 

 

 待补充.....................................................