C++面向对象程序设计

来源:互联网 发布:淘宝宝贝图片怎么上传 编辑:程序博客网 时间:2024/06/06 14:16

前言

面向对象有三个核心概念,抽象、继承、多态。

数据抽象就是多类事物的共同特征抽象出来,抽象出一个类,这个类定义了接口。

继承是描述类与类之间的关系,是一种包含关系。比如苹果和梨子都是水果,水果是基类,它包含苹果和梨子的共同特征(数据特征、行为特征),而苹果和梨是派生类,有它们自己独有的特征。

多态,c++主要指的是动态绑定,根据里氏替换原则,使用父类的地方都可以用子类去替换。在运行时,根据实际类型(c++称为动态类型)来调用实际类型的方法,已达到复用的目的。

我们在之前 类 的那一章已经分析过数据抽象的基本知识了。本文主要分析继承和动态绑定(更加严格来说)。

继承和动态绑定对程序有两方面的影响:

  1. 更容易的定义与其他类相似但不完全相同的新类 (继承)
  2. 在一定程度上忽略这些彼此相似的类编写程序(动态绑定)
// 继承class Quote {public:    std::string isbn() const;    // 希望派生类定义自己的版本    virtual double net_price(int n) const;  };class Bulk_quote : public Quote {public:    // 1. virtual 可加可不加;2.override显示的告诉编译器这个方法重写了基类的虚函数    virtual double net_price(int n) const override; };// 动态绑定double print_total(ostream &os, const Quote &item, int n) {    // 使用基类引用编程,运行时根据引用的动态类型选择实际类型的虚函数    double ret = item.net_price(n);    cout << ret << endl;}Quote basic;Bulk_quote bulk;print_total(cout, basic, 20); // 调用 Quote::net_priceprint_total(cout, bulk, 20); // 调用 Bulk_quote::net_price

注意:

c++ 中,使用基类的引用或者指针调用虚函数来发生动态绑定


1. 定义基类和派生类

  • 基类

基类集中了派生类共有的特征,通过将函数设置成虚拟函数来说明希望派生类定义自己的版本,而如果派生类没有定义自己的版本,则会继承基类的版本,并且隐式保持着虚拟的状态。

class Quote {public:    Quote() = default;    virtual ~Quote() = default; // 对析构函数进行动态绑定 };

基类通常需要定义一个虚析构函数,即使该函数不执行任何实际操作。因为,我们知道动态绑定需要基类定义虚函数,如果基类的析构函数不是虚拟的,用基类的引用或者指针来编程,并不会发生动态绑定。

任何构造函数之外的,非静态的函数都可以是虚函数。另外,virtual关键字只能出现在类的内部。

派生类继承基类的成员,但是派生类的成员函数不一定有权访问继承而来的成员。需要看基类定义的成员访问控制符。

  • 派生类

派生类通过类派生列表指明从哪个(哪些)基类继承而来。

class Bulk_quote : public Quote {public:    Bulk_quote() = default;    double net_price() const override;private:    int min_qty = 0; // 可以定义派生类自己的数据成员    double discount = 0.0;  };

类派生列表包含了派生访问说明符,这个符号说明的是如何继承基类的成员,即基类的成员对于派生类的用户的可见性。和基类的成员访问符的作用没有关系。

派生类可以不覆盖继承的虚函数。另外,派生类中覆盖的函数不必使用virtual关键字。但是一定得记住的是,这个函数一定还是虚的。可以通过override显示告诉编译器,我覆盖了虚方法,请检查。

  • 派生类向基类的类型转换

派生类对象组成部分:

  1. 派生类自己定义的非静态成员子对象。
  2. 继承基类对应的子对象,如果是多继承,则有多个

正因为派生类对象中含有与其基类对应的组成部分,所以

  1. 可以把派生类对象当成基类对象来使用
  2. 能将基类的指针或引用绑定到派生类的对象上来
Quote item; // 基类Bulk_quote bulk; // 派生类 Quote *p = &item; // p 指向 Quote对象 p = &bulk; // p 指向 bulk 的 Quote 部分Quote &r = bulk; // r 绑定到 bulk 的 Quote 部分
  • 关于构造函数

记住一点:每个类控制它自己的成员初始化过程

因此,我们很容易明白,派生类对象含有从基类继承而来的成员,但是派生类不能直接初始化它们。派生类必须使用基类的构造函数来初始化它们。

Bulk_quote(const string &book, double p, int qty, double disc)        : Quote(book, p), min_qty(qty), discount(disc) {}        // 使用基类构造函数初始化基类部分

另外,初始化的顺序是:

  1. 首先初始化基类的部分
  2. 按照声明的顺序依次初始化派生类的数据成员

如果没有指出基类的构造函数,执行基类的默认初始化。

  • 关于静态成员

如果基类定义了静态成员,在整个继承体系中只存在该成员的唯一定义。

  • 防止继承的发生 (final)

final 关键字有两个作用

  1. 定义类为final,不允许其他类继承它
  2. 定义类中的方法为final,不允许其它函数覆盖它

2. 类型转换与继承

理解类型转换非常重要。关键在于理解静态类型和动态类型的关系。

注意:

和内置指针一样,智能指针也支持派生类向基类的转换

变量有静态类型和动态类型。

  • 静态类型指变量声明的类型,编译期可以确定的
  • 动态类型指变量表示的对象的类型,是内存中对象的类型,运行时才可知

那么,容易知道,如果变量或者表达式不是引用也不是指针,那么它的动态类型与静态类型永远是一致的。只有指针或者引用才有可能静态类型与动态类型不一致。

不存在从基类向派生类的隐式类型转换(判断的关键:编译期来检查静态类型转换是否合法)

Quote base;Bulk_quote *bulkp = &base; // 错误:不能将基类转换成派生类Bulk_quote &bulkRef = base; // 错误:不能讲基类转换成派生类Bulk_quote bulk;Quote *itemp = &bulk; // 正确Bulk_quote *bulkp = itemp; // 错误:不能将基类转换成派生类,即使动态类型是派生类

当然,如果转换是安全的,可以使用static_cast或者dynamic_cast强制转换

对象之间不存在类型转换,如果派生类转基类,会被切掉一部分,这些是由拷贝构造函数和赋值操作符来决定的。

Bulk_quote bulk;Quote item(bulk); // 使用Quote::Quote(const Quote &) 拷贝构造函数 item = bulk;  // 使用Quote::operator=(const Quote &) 赋值运算符

3. 虚函数

使用引用或者指针来发生动态绑定

非虚函数的调用发生在编译期,根据静态类型来调用

一旦函数声明成虚函数,它在所有派生类中都是虚函数

注意

1 派生类的函数如果覆盖继承来的虚函数,它的形参必须与被覆盖的基类虚函数完全一致

2 返回类型也必须与基类函数一致,有一个例外,但返回类型是类本身的指针或引用时,这种例外必须要从 D 到 B 的类型转换是可访问的。

关于默认实参

如果虚函数使用默认实参,基类和派生类的定义的默认实参最好一致。

回避虚函数机制

即使用特定版本的虚函数,使用作用域操作符 ::

double undiscounted = baseP -> Quote::net_price(42); // 强制使用基类的版本而不管动态类型

通常的使用场景是,基类的虚函数执行了一些共同的任务,派生类的版本需要直接调用,并执行一些自己的操作。

如果没有使用作用域操作符,派生类虚函数调用基类版本,会在运行时调用自己,从而导致无限递归。


4. 抽象基类

某些情况下,一些类并不需要被创建,它的作用只是定义一个接口,或者提供公共部分的特征。我们称这样的类为抽象类。c++中并没有像java那样可以声明类为抽象类。c++通过将虚函数声明成纯虚函数,来将一个类作为抽象类。

抽象类可以作为引用和指针使用。

class Disc_quote : public Quote { // Disc_quote 为抽象类,不能创建对象public:    Disc_quote() = default;    double net_price(int) const = 0; // 声明为纯虚函数protected:    int quantity = 0;    double discount = 0;    };// 纯虚函数的定义必须在类外double Disc_quote::net_price(int t) {    return discount;}

注意 :

派生类必须要覆盖抽象基类的纯虚函数,否则它们仍然是抽象的。


5. 访问控制与继承

关于这个问题,分两部分来看。

一方面,基类使用访问控制符来表达基类的成员的是否可以访问,三个级别

  • public: 公有的,所有人可见
  • protected: 受保护的,仅派生类和友元可见(用户不可见!!!)
  • private: 私有的,仅友元可见

注意

派生类的成员或友元,只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问权(这时派生类仅仅是一个用户)

class Base {protected:    int prot_mem;   };class Sneaky : public Base {    friend void clobber(Sneaky &);    friend void clobber(Base &);    int j;  };// 因为是Sneaky的友元,可以访问Sneaky的private和protectedvoid clobber(Sneaky &s) {    s.j = s.prot_mem = 0; // 正确,clobber能够访问Sneaky对象的private和protected成员}// 虽然是友元void clobber(Base &b) {    b.prot_mem = 0; // 错误,不能访问一个基类对象的protected成员}

另一方面,继承有访问控制,分为公有继承、私有继承、受保护继承。这个继承说明的仅仅是基类的成员被派生类继承了之后,它的访问控制权限要如何改变。受到两个因素的影响,其一是在基类中的访问控制符,其二是继承的访问控制符。

  1. 公有继承,基类中访问控制符是什么样,照样是什么样
  2. 受保护继承,基类中是public,则变成protected,基类是protected,还是protected
  3. 私有继承,基类所有都变成private

一定要注意的是,这里仅仅指的基类的成员被继承之后,在派生类的控制访问符变成什么样。

派生访问说明符 对派生类的成员(或友元)能否访问到其直接基类的成员没有什么关系

注意,直接基类!!!

通过以上,我们知道,派生访问符目的是控制派生类用户(包括派生类的派生类)对于基类成员的访问权限。

关于派生类向基类转换的可访问性

记住一点,只有当D公有继承B时,用户代码才能使用派生类向基类的转换;如果D继承B的方式是受保护或者私有继承,则用户代码不能使用该转换

而不论D以什么方式继承B,D的成员函数和友元函数都能够使用派生类向基类的转换。

关于友元和继承

友元关系不传递,不继承

如何改变个别成员的可访问性

使用using声明

class Base {public:    int size() const {return n;}protected:    int n;  };class Derived : private Base { // 私有继承,默认应该是私有的,但。。。public:    using Base::size; // 基类size方法的访问权限由前面的public决定protected:    using Base::n; // 基类n数据成员由前面的protected决定};

默认继承保护级别!!!!

  1. class 默认是私有继承
  2. struct 默认是公有继承

6. 继承中的类作用域

记住一点,派生类的作用域嵌套在其基类的作用域之内。

如果一个名字在派生类作用域无法解析,则在外层基类作用域中查找。

注意只会往当前派生类到继承链的顶端找,不会往继承链的末端找

名字查找优先于类型检查

因此,声明在内存作用域的函数并不会重载声明在外层作用域的函数,也就是说派生类的函数不会重载其基类的函数,只会隐藏基类的函数,即使派生类的成员和基类的成员形参列表不一致。(名字查找,只查找函数名)

但是,如前面所说,可以使用作用域操作符调用基类的成员

struct Base {    int func(); };struct Derived : Base {    int func(int);  };Derived d;Base b;b.func(); // 调用 Base::func()d.func(10); // 调用 Derived::func()d.func(); // 错误:基类的func被隐藏了d.Base::func(); // 正确:通过作用域操作符调用 Base::func()

关于虚析构函数

定义虚析构函数的意义在于,如果delete指向派生类对象的基类指针,根据动态绑定,调用的是派生类的析构函数,这正是我们想要的。

如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针会产生未定义的行为。

我们之前还说过一个三\五法则,如果一个类需要析构函数,那么它同样需要拷贝和赋值。注意,基类的析构函数并不遵循,这是一个重要的例外。

虚拟析构函数将阻止合成移动操作。

析构顺序(和构造顺序相反):

  1. 派生类的析构函数首先执行
  2. 然后是基类的析构函数(直到继承链的顶端)