C++ 学习笔记——七、类的高级特性

目录:点我

一、运算符重载

类的重载特性允许将C++现有的运算符的功能改为其他形式,举个栗子:

// 两个时间相加非重载版
class Time {
private:
	int hours;
	int minutes;
public:
	Time Sum(const Time & t) const;  // 第一个const的作用是防止更改形参,第二个const的作用是防止更改对象
};
Time Time::Sum(const Time & t) const {
	Time sum;
	sum.minutes = minutes + t.minutes;
	sum.hours = hours + t.hours + sum.minutes / 60;
	sum.minutes %= 60;
	return sum;
}

此时要实现两个Time对象相加,需要调用成员方法Sum,举个栗子:

Time a, b;
a.Sum(b);

而使用重载方式则更加简单,举个栗子:

// 两个时间相加重载版
class Time {
private:
	int hours;
	int minutes;
public:
	Time operator+(const Time & t) const;
};
Time Time::operator+(const Time & t) const {
	Time sum;
	sum.minutes = minutes + t.minutes;
	sum.hours = hours + t.hours + sum.minutes / 60;
	sum.minutes %= 60;
	return sum;
}

此时可以使用下面两种方法实现两个对象的相加方法:

Time a, b;
a.operator+(b); // first
a + b; // second

可重载运算符:

+-*/%^
&|~=!=<
>+=-=*=/=%=
^=&=|=<<>>>>=
<<===!=<=>=&&
||++,->->*
()[]newdeletenew []delete []

注意:

  • 重载运算符不能改变需要的操作数个数,例如加法运算需要两个操作数,不能仅使用一个操作数完成加法运算。
    a + b; // 正确
    + a; // 错误
    
  • 重载运算符不改变运算符原有的优先级。
  • 不能创建新的运算符(只可重载标准运算符)。
  • 不能重载以下运算符:
    • sizeof:sizeof运算符
    • .:成员运算符
    • .*:成员指针运算符
    • :::作用域解析运算符
    • ?::条件运算符
    • typeid:一个RTTI运算符
    • const_cast:强制类型转换运算符
    • dynamic_cast:强制类型转换运算符
    • reinterpret_cast:强制类型转换运算符
    • static_cast:强制类型转换运算符
  • 以下运算符只能通过成员函数重载:
    • =:赋值运算符
    • ():函数调用运算符
    • []:下标运算符
    • ->:通过指针访问类成员的运算符

使用一个参数就可以调用的构造函数定义了从参数类型到类类型的转换:

Star(const char *);  // 将char转换为Star
Star(const Spectral &, int members = 1);  // 将Spectral转换为Star
Star north;
north = "polaris";  // 调用了第一个方法进行隐式转换
Star::operator=(const char *);  // 上一条语句调用了该函数,使用第一个方法生成Star对象

对于隐式的转换可能是不安全的,因此可以使用关键字 explicit 将其禁止,但是显示调用仍然是可行的:

class Star {
	...
public:
	explicit Star(const char *);
	...
}
Star north;
north = "polaris";  // not allowed
north = Star("polaris");  // allow

二、友元函数

1. 介绍

在为类重载二元运算符时,常常需要友元,举个栗子:

// 栗子:
A = B * 2;

// 重载后的运作方式:
A = B.operator*(2);

// 调换顺序时产生了问题:
A = 2 * B; // 由于数字2不是该类的对象,因此没有这种操作,出现错误

为解决上述问题,可以考虑使用非成员函数,举个栗子:

A = operator*(2, B); // 利用传递参数解决问题

但是这样又出现了新的问题,由于对象的成员变量通常是私有的,因此不能直接用非成员函数访问。

为了解决这个问题,便出现了友元函数。

2. 使用友元

举个栗子:

friend Time operator*(double m, const Time & t);

该原型意味着下面两点:

  • 虽然 operator*() 函数是在类声明中声明的,但它不是成员函数,因此不能使用成员运算符来调用。
  • 虽然 operator*() 函数不是成员函数,但它与成员函数的访问权限相同。

接下来编写函数定义,由于它不是成员函数,因此不需要使用Time::限定符,并且不需要使用关键字friend

Time operator*(double m, const Time & t) {
	Time result;
	long totalminutes = t.hours * mult * 60 + t.minutes * mult;
	result.hours = totalminutes / 60;
	result.minutes = totalminutes % 60;
	return result;
}

通过上述定义,就解决了非成员函数访问私有变量的问题,下述代码可以正常进行转换:

A = 2 * B;
A = operator*(2, B); // 与上一行等价

需要注意的是,对于成员重载函数来说,只需要一个参数,因为另一个参数会通过 this 指针传递;而对于友元重载函数来说,需要两个参数,即需要显示的指出对象。

三、类和动态内存分配

1. 静态成员

类声明中不能初始化静态成员变量,因为声明描述了如何分配内存,但并不能分配内存,因此需要在类声明之外使用单独的语句来进行初始化,所有对象的静态变量共享同一个内存单元:

class T {
	static int st;
}
int T::st = 0;

类中还可以存在静态成员函数,即将成员函数声明为静态的(关键字 static),其有两个特性:

  • 不能通过对象调用静态成员函数,也不能对其使用 this 指针,如果其在共有部分声明,可以使用域解析运算符调用;
  • 静态成员函数不与特定的对象相关联,因此只能使用静态数据成员。

2. 返回类型

  • 返回指向 const 对象的引用效率更高,因为不需要使用拷贝构造函数:
    const Vector & Fun(const Vector & v) {
    	return v;
    }
    
  • 返回指向非 const 对象的引用通常用于重载复制运算符以及与 cout 一起使用的 << 运算符,前者旨在提高效率,后者则必须这样做:
    Vector & Fun(Vector & v) {  // 形式
    	return v;
    }
    
    String s1("Good stuff");
    String s2, s3;
    s3 = s2 = s1;  // 返回对象或对象的引用均可,后者效率更高
    
    cout << s1 << "is coming!";  // 必须返回ostream的引用,否则将要求调用ostream类的拷贝构造函数
    
  • 返回对象通常为被调用函数中的局部变量,若使用引用则会出错,因为局部变量已被销毁:
    Vector v1(1), v2(2), v3;
    v3 = v1 + v2;  // 实际上重载了+,返回的是一个新对象,原局部对象已被销毁
    
  • 返回 const 对象通常用于防止出错:
    const Vector Fun(Vector & v) {  // 形式
    	return v;
    }
    
    v1 + v2 = v3;  // 接前一个例子,这样的代码实际上是可行的,因为结果会存储在临时变量
    if(v1 + v2 == v3)  // 通常试图编写这样的代码
    if(v1 + v2 = v3)  // 但是写错成这样,若使用const对象则可防止出错,因为不可更改
    

3. 指向对象的指针

Classname * p = new Classname(value);  // value类型为Typename
// 将调用构造函数
Classname(Typename);
// 如果不存在二义性,下述语句则使用默认构造函数
Classname * p = new Classname;

1
对于析构函数,调用顺序如下:

  • 如果对象是动态变量,则当执行完定义该对象的程序块时调用析构函数;
  • 如果对象是静态变量,则在程序结束时调用析构函数;
  • 如果对象是 new 创建的指针,仅当使用 delete 删除对象时调用析构函数(或者程序结束)。

4. 成员初始化列表

对于构造函数可以使用如下方法:

Classy::Classy(int n, int m) : mem1(n), mem2(0), mem3(m) {
	...
}
  • 这种格式只能用于构造函数;
  • 必须用这种格式初始化非静态 const 数据成员(C++ 11 之前);
  • 必须用这种格式初始化引用数据成员;
  • 被初始化的顺序与定义顺序相同,与列表顺序无关。

四、继承

  • 可以在已有类的基础上添加功能;
  • 可以给类添加数据;
  • 可以修改类方法的行为。

1. 介绍

类的继承特性,顾名思义,就是在某个类的基础上新增一部分功能从而实现新的类,举个栗子:

// 现在有一个基础类Time,它的继承类可以如下定义:
class Time2 : public Time {
	...
};

上述继承的方法为公有继承(public),子类可以继承父类的成员数据和函数,并且是按照父类的权限进行继承,例如父类的数据为私有类型,子类继承后也为私有类型。

子类不能直接访问父类的私有成员,必须通过父类的方法进行访问。

由于在创建子类对象的时候,需要先创建父类对象,因此构造函数的使用有了新的要求,举个栗子:

Son Son(int a, int b, int c) : Dad(a, b);

上述代码显式的调用了构造函数,根据继承的特性,对于构造函数与析构函数的调用过程如下:

  • 父类构造函数
  • 子类构造函数
  • 子类析构函数
  • 父类析构函数

相似的,继承时也可以使用拷贝构造函数:

Son Son(int a, const Dad & b) : Dad(b);

并且可以使用成员初始化列表语法:

Son Son(int a, int b, int c) : Dad(a, b), mem1(a), mem2(b), mem3(c);

可以使用派生类对象来初始化基类对象:

Son son;
Dad dad(son);
Dad(const Son &);  // 构造函数原型

对于形参是基类引用的构造函数,可以引用派生类,由于隐式拷贝构造函数的存在,也可以进行赋值:

Dad(const Dad &);  // 构造函数原型、拷贝构造函数原型
Dad dad(son);  // 引用派生类
Dad dad = son;  // 用派生类赋值

派生类可以使用域解析运算符调用父类的公有方法:

void Son::view() const {
	Dad::view();
	cout << mem3 <<endl;
}

2. 多态公有继承

派生类对象使用基类的方法时,可能希望同一个方法在派生类和基类中的行为是不同的,也就是说同样的方法应当取决于调用该方法的对象,这种行为称为多态,有两种重要机制可用于实现多态公有继承:

  • 在派生类中重新定义基类的方法;
  • 使用虚方法。

要讨论多态与继承的问题,首先需要了解一个例子:

class Shape {
   protected:
      int width, height;
   public:
      Shape(int a=0, int b=0)
      {
         width = a;
         height = b;
      }
      int area()  // 原始定义,默认采用的版本
      {
         cout << "Parent class area :" <<endl;
         return 0;
      }
};
class Rectangle: public Shape{
   public:
      Rectangle(int a=0, int b=0):Shape(a, b) { }
      int area ()  // 重新定义,根据对象选择使用哪个版本
      { 
         cout << "Rectangle class area :" <<endl;
         return (width * height); 
      }
};
class Triangle: public Shape{
   public:
      Triangle(int a=0, int b=0):Shape(a, b) { }
      int area ()  // 重新定义,根据对象选择使用哪个版本
      { 
         cout << "Triangle class area :" <<endl;
         return (width * height / 2); 
      }
};

上述代码定义了一个父类与两个子类,现在我们尝试使用两个子类的area()方法:

// 程序的主函数
int main( )
{
   Shape *shape;
   Rectangle rec(10,7);
   Triangle  tri(10,5);
   shape = &rec;
   shape->area();
   shape = &tri;
   shape->area();
   return 0;
}

输出结果如下:

Parent class area
Parent class area

程序输出的结果是父类方法 area() 的调用结果,这说明对象的定义决定了它选择哪个版本来输出(定义为父类 shape ,所以输出为父类的版本),这是因为调用的函数 area() 被编译器设置为基类中的版本,这就是所谓的静态多态,或静态链接——函数调用在程序执行前就准备好了。有时候这也被称为早绑定,因为 area() 函数在程序编译期间就已经设置好了,而编译的时候发现它的定义为父类,因此选择父类的版本。

解决这个问题的方法很简单,只需要让编译器不要进行早绑定即可,也就是所谓的动态链接或后期绑定,方法如下:

class Shape {
   protected:
      int width, height;
   public:
      Shape( int a=0, int b=0)
      {
         width = a;
         height = b;
      }
      virtual int area()  // 改动在这,加入了关键字virtual
      {
         cout << "Parent class area :" <<endl;
         return 0;
      }
};

即将父类的函数 area()设 定为虚函数,此时的输出为:

Rectangle class area :  // 自动选择派生类的版本
Triangle class area :  // 自动选择派生类的版本

这是因为 virtual 关键字将静态链接(早绑定)改为了动态链接(后期绑定),使程序在运行时再进行选择,此时编译器分析被引用的对象实际类型后,选择采用派生类的版本。该效果同样适用于被指针指向的对象。

假设现在想要在基类中定义虚函数,以便在派生类中重新定义该函数更好地适用于对象,但是在基类中又不想对虚函数给出有意义的实现,这个时候就会用到纯虚函数,我们可以把基类中的虚函数 area() 改写如下:

class Shape {
   protected:
      int width, height;
   public:
      Shape( int a=0, int b=0)
      {
         width = a;
         height = b;
      }
      // pure virtual function
      virtual int area() = 0;
};

=0 就是在告诉编译器,函数没有主体,上面的虚函数是纯虚函数,值得注意的是,若使用 =0 表示纯虚函数,可以将前面的 virtual 省略。在 C++ 中,纯虚函数也可以有自己的定义内容,不需要一定为空。

由于这种特性的存在,还可以用于析构函数,即虚析构函数:

virtual ~Dad() {}
Dad *p = new Son();  // 定义并初始化一个指向派生类的Dad类型指针

如上述指针 p ,倘若析构函数不是虚的,那么程序结束后会执行父类的析构函数,而不是派生类的析构函数,这样可能导致内存泄漏。反之若析构函数是虚的,那么程序结束后系统会判断指针所指对象的具体类型,从而调用派生类的析构函数。

3. 继承方式

特征公有继承保护继承私有继承
公有成员变成派生类的公有成员派生类的保护成员派生类的私有成员
保护成员变成派生类的保护成员派生类的保护成员派生类的私有成员
私有成员变成只能通过基类接口访问只能通过基类接口访问只能通过基类接口访问
能否隐式向上转换是(但只能在派生类中)

其中 protected 比较特别,它只在派生关系中发挥作用。对于外部世界来说,类的 protected 成员与 private 成员相似,均不可访问;对于派生类来说,protected 成员与 public 成员相似,可以被派生类访问。

4. 联编

程序调用函数时,将使用哪个可执行代码块是由编译器来决定的,将源代码中的函数调用解释为执行特定的函数代码块这个过程被称为函数名联编(binding)。在 C++ 中,由于函数重载的存在,编译器必须查看函数参数以及函数名才能确定使用哪个版本。若这个过程在编译时完成,则称为静态联编(static binding),又称为早期联编(early binding)。由于虚函数的存在,因此可将该过程推迟到程序运行阶段,来选择正确的虚方法的代码,这种方式称为动态联编(dynamic binding),又称为晚期联编(late binding)。

由于虚方法的存在,导致指针和引用具有兼容性,即派生类引用或指针转换为基类引用或指针,这被称为强制转换(upcasting),这使公有继承不需要进行显示类型转换。这种方式不会出错的原因是子类是父类的扩充,对父类的成员拥有完全控制权(包括通过拥有方法控制私有成员),因此是可行的。

相反的过程称为向下强制转换(downcasting),如果不采用显示类型转换,这种转换是不允许的。

Ⅰ. 为什么有两种类型的联编?为什么默认为静态联编?

  • 在效率方面,为使程序能够在运行阶段进行决策,必须采取一些方法来跟踪基类指针或引用指向的对象类型,这额外增加了处理开销。而对于不作为基类的类、不重定义基类方法的类,均不需要动态联编,因此静态联编更合理,效率也更高。
  • 在概念模型方面,设计类时,可能包含一些不在派生类重新定义的成员函数,因此采用静态联编效率更高,且指出不要重新定义该函数。因此仅将哪些预期将被重新定义的方法声明为虚的是更有效的方式。

Ⅱ. 虚函数的工作原理

编译器处理虚函数的方法是:给每一个对象添加一个隐藏成员,隐藏成员中保存了一个指向函数地址数组的指针,这种数组称为虚函数表(virtual function table, vtbl),里面存储了为类对象进行声明的虚函数的地址。例如基类对象包含一个指向基类中所有虚函数的地址表的指针;派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;如果没有新定义,该虚函数表将保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到虚函数表中,由于对象中保存的是指针,因此不论虚函数是多是少,都只需要在对象中添加一个地址成员。总结一下就是:

class Dad {
	int a;
public:
	int fun1();  // 原版
	int fun2();  // 原版
};

class Son : Dad {
	int b;
public:
	int fun1();  // 重写
	int fun2();  // 重写
}

那么两个类中均存在虚函数表:

Dad 成员方法的含义表地址:2008
fun14064
fun26400
Son 成员方法的含义表地址:2096
fun16820
fun27280

此时两个类所创建的对象中存在指针:

指针字段含义vptr
dad2008
son2096

此时运行以下代码,将分为四个步骤执行:

Son son();
Dad * p = &son;
p->fun1();
  • 获取指针所指向的 vptr 地址;
  • 前往 vptr 地址表(2096);
  • 获取函数 fun1 的地址;
  • 前往函数 fun1 的地址(6820),并执行。

Ⅲ. 注意事项

  • 基类中声明的虚函数,在派生类及其派生类中也是虚的;
  • 如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法;
  • 如果定义的类被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚的;
  • 构造函数不能是虚函数,因为派生类会调用基类和自己的构造函数;
  • 析构函数应当为虚函数,除非类不作为基类;
  • 友元不能是虚函数,因为友元不是类成员;
  • 如果派生类没有重新定义函数,将使用基类的版本;
  • 如果派生类位于派生链中,将使用最新的虚函数版本;
  • 以上两条不适用于被隐藏的方法:
    class Dad {
    public:
    	virtual void fun(int a) const;  // 原版
    };
    
    class Son : Dad {
    public:
    	virtual void fun() const;  // 编写同名不同函数原型的虚函数
    }
    
    Dad dad;
    dad.fun();  // valid
    dad.fun(5);  // invalid
    
    此时新定义函数 fun 不接受任何参数,由于同名且不生成两个重载版本,导致基类方法被隐藏。因此如果重新定义基类方法,因保证函数原型相同;但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针,这种特性被称为返回类型协变(covariance of return type),因为允许返回类型随类类型的变化而改变:
    class Dad {
    public:
    	virtual Dad & fun(int a);  // 原版
    };
    
    class Son : Dad {
    public:
    	virtual Son & fun(int a);  // 编写返回类型为派生类的方法
    }
    
    这种例外只适用于返回值,而不适用于参数。
  • 如果基类声明被重载了,则应在派生类中重新定义所有的基类版本:
    class Dad {
    public:
    	virtual void fun(int a) const;  // 原版
    	virtual void fun() const;  // 原版
    };
    
    class Son : Dad {
    public:
    	virtual void fun(int a) const;  // 重写
    	virtual void fun() const;  // 重写
    }
    
  • 构造函数、析构函数、赋值运算符(=)、友元函数均不能被继承。
  • 派生类要想使用基类的友元函数,需要通过对象的基类指针或引用来访问,因为编译器会将派生类强制转换为基类后再调用友元函数。

5. 抽象基类

假设目前有一个椭圆类,现在需要构造一个圆类,由于椭圆类相对于圆类存在许多冗余成员,因此直接继承会造成空间的浪费,而重新定义则又浪费已有实现的部分代码。另一种解决办法是从椭圆类和圆类中抽象出共性,并将其放入一个单独的类 ABC 中,这样便可以使用基类指针同时管理椭圆类和圆类的对象,由于一些方法在类 ABC 中不可能实现,因此通过使用纯虚函数来提供,而这样的类通常不能创建对象。也就是说,包含纯虚函数的类只能用作基类。

纯虚函数允许有定义,但是派生类必须重写纯虚函数,从而确保派生的所有组件都至少支持 ABC 指定的功能:

void Move(int nx, int ny) = 0;  // 原型声明
void ABC::Move(int nx, int ny) {  // 提供定义
	x = nx;
	y = ny;
}

6. 动态内存分配

如果基类使用动态内存分配,并重新定义赋值和拷贝构造函数,对派生类会产生怎样的影响?

Ⅰ. 派生类不使用 new

  • 析构函数:由于派生类没使用 new ,因此可以采用默认的析构函数。派生类的析构函数执行完自己的代码后会自动调用基类的析构函数,因此能够妥善处理基类 new 的内存,因此派生类不需要显示定义析构函数。
  • 拷贝构造函数:由于派生类没使用 new ,因此使用默认的拷贝构造函数可以完成对成员变量的复制。对于继承得到的基类成员变量,则会采用基类中显示定义的拷贝构造函数,因此也能妥善处理这部分成员变量,因此派生类不需要显示定义拷贝构造函数。
  • 赋值运算符:与拷贝构造函数相同,因此也不需要显示定义赋值函数。

Ⅱ. 派生类使用 new

  • 析构函数:由于派生类使用了 new ,因此需要显示定义析构函数来 delete 对应的内存空间。
  • 拷贝构造函数:对于基类的数据,会调用基类的拷贝构造函数;对于派生类自己的变量,则需要显示的定义拷贝构造函数来处理:
    Son::Son(const Son & son) : Dad(son) {  // 需要将成员初始化列表中的引用传递给基类的拷贝构造函数
    	...  // 这样做可行的原因是基类指针和引用能够兼容管理派生类的对象
    }
    
  • 赋值运算符:需要显式定义并用基类的赋值运算符处理:
    Son & Son::operator=(const Son & son) {
    	Dad::operator=(son);  // 调用基类的赋值函数复制基类中的变量
    	...  // 复制派生类的变量
    }
    
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

BeZer0

打赏一杯奶茶支持一下作者吧~~

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值