C++ Primer 5th学习笔记14 面向对象程序设计

面向对象程序设计

1 OOP概述

  面向对象程序设计的核心思想是数据抽象,继承和动态绑定。通过使用数据抽象,可以将类的接口与实现分离;使用继承定义相似的类型并对其相似关系建模;使用动态绑定,可以在一定程度上忽略相似类型的区别。
继承
  通过继承联系在一起的类构成一种层次关系。通常在层次关系的根部有一个基类,其他类则直接或间接从基类继承而来,这些继承的类称为派生类。
  在C++语言中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。对于某些函数,基类希望其派生类各自定义适合自身的版本,此时基类就将这些函数声明为虚函数。在以前的例子中,我们定义了 一个名为Quote的类,并将其作为层次关系中的基类。Quote的对象表示按原价销售的书籍。Quote派生出另一个名为Bulk_quote的类,表示可以打折销售的书籍。示例如下:

class Quote{
public:
    std::string isbn() const;
    virtual double net_price(std::size_t n) const;
};

派生类必须通过使用类派生列表明确指出其是从哪个基类继承而来。类派生列表的形式是:首先是一个冒号,后紧跟以逗号分隔的基类列表,其中每个基类前面可以有访问说明符:

class Bulk_quote:public Quote {    //Bulk_quote继承了Quote
public:
    double net_price(std::size_t n) const override;
};

由于Bulk_quote在其派生列表中使用了关键字public,因此可以把Bulk_quote的对象当成Quote的对象来使用。
  派生类必须在其内部对所有重新定义的虚函数进行声明,派生类可以在这样的函数之前加上virtual关键字

动态绑定
  通过动态绑定,能用同一段代码分别处理QuoteBulk_quote的对象。例如以下示例:

double print_total(ostream &os, const Quote &item, size_t n)
{
    //根据传入item形参的对象类型调用Quote::net_price
    //或者Bulk_quote::net_price
    double ret = item.net_price(n);
    os << "ISBN: " << item.isbn()    //调用Quote::isbn
       << " # sold: " << n << " total due: " << ret << endl;
    return ret; 
}
//调用示例如下:
//basic的类型是Quote; bulk的类型是Bulk_quote
print_total(cout, basic, 20);    //调用Quote的net_price
print_total(cout, bulk, 20);    //调用Bulk_quote的net_price

2 定义基类和派生类

2.1 定义基类

成员函数与继承
  派生类需要重新定义相关操作以覆盖(override)从基类继承而来的旧定义。基类必须将其两种成员函数区分开:一种是基类希望其派生类进行覆盖的函数;另外一种是基类希望通过派生类直接继承而不要改变的函数。对于前者,基类通常将其定义为虚函数,当我们使用指针或引用调用虚函数时,该调用将被动态绑定。
  基类通过在其成员函数的声明语句之前加上关键字virtual使得该函数执行动态绑定。任何构造函数之外的非静态函数都可以是虚函数。关键字virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。若基类把一个函数声明为虚函数,则该函数在派生类中隐式地也是虚函数。

访问控制与继承
  派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定有权访问从基类继承而来的成员。受保护成员:基类希望其派生类有权访问该成员,同时禁止其他用户访问。

2.2 定义派生类

  派生类必须通过使用类派生列表指出其从哪个基类继承而来的。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有以下三种访问说明符中的一个:public、protected或者private。示例如下:

class Bulk_quote : public Quote{    //Bulk_quoto继承自Quoto
public:
    Bulk_quote() = default;
    Bulk_quote(const std::string&, double, std::size_t, double);
    //覆盖基类的函数版本以实现基于大量购买的折扣政策
    double net_price(std::size_t) const override;
private:
    std::size_t min_qty = 0;    //适用折扣政策的最低购买量
    double discount = 0.0;    //以小数表示的折扣额
};

派生类中的虚函数
  派生类可以在它覆盖的函数前使用virtual关键字,C++新标准允许派生类显示地注明它使用某个成员函数覆盖了其继承的虚函数。具体做法是:在形参列表后面、或者再const成员函数的const关键字后面、或者在引用成员函数的引用限定符后面添加一个关键字override

派生类对象及派生类向基类的类型转换
  一个派生类对象包含多个组成部分:一个含有派生类自己定义的(非静态)成员的子对象,以及一个与该派生类继承的基类对应的子对象。
由于派生类对象中含有与其基类对应的组成成分,使用可以把派生类的对象当成基类对象来使用,而且也能将基类的指针或引用绑定到派生类对象中的基类部分上,这种转换称为派生类到基类的类型转换。示例如下:

Quote item;    //基类对象
Bulk_quote bulk;    //派生类对象
Quote *p = &item;    //p指向Quote对象
p = &bulk;    //p指向bulk的Quote部分
Quote &r = bulk;    //r绑定到bulk的Quote部分

派生类构造函数
  和其他创建了基类对象的代码一样,派生类必须使用基类的构造函数来初始化它的基类部分。每个类控制其成员的初始化过程。
首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。

派生类使用基类成员
  派生类可以访问基类的公有成员和受保护成员:

//如果达到了购买书籍的某个最低限量值,就可以享受折扣价格了
double Bulk_quote::net_price(size_t cnt) const
{
    if (cnt >= min_qty)
        return cnt * (1- discount) * price;
    else
    return cnt * price;
};

关键概念:遵循基类的接口
  每个类负责定义各自的接口,要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。派生类对象不能直接初始化基类的成员

继承与静态成员
  如果基类定义了一个静态成员,则在整个继承体系中子存在该成员的唯一定义。不论从基类派生出来多少个派生类,对于每个静态成员来说都只存在唯一的实例。

class Base{
public:
    static void statmem();
}

class Derived : public Base{
    void f(const Derived);
};

静态成员遵循通用的访问控制规则,若基类中的成员是private的,则派生类无权访问它

派生类的声明
  派生类的声明形式如下:

class Bulk_quote : public Quote;    //错误:派生列表不能出现在这里
class Bulk_quote;    //正确:声明派生类的正确方式

被用作基类的类
  一个类是基类,同时它也可以是一个派生类:

class Base { /*    */ };
class D1 : public Base { /*    */ };
class D2 : public D1 { /*    */ };

在上例中,Base是D1的直接基类,同时是D2的间接基类。

防止继承的发生
  C++11中提供了一种防止继承发生的方法,即在类名后跟一个关键字final

class NoDerived final { /* */ };    //NoDerived不能作为基类
class Base { /* */ };
//Last是final的;因此不能继承Last
class Last final : Base { /* */ };    //Last不能作为基类
class Bad : NoDerived { /* */ };    //错误:NoDerived是final的
class Bad : Last { /* */ };    //错误:Last是final的

2.3 类型转换与继承

  存在继承关系的类是一个重要的例外:可以将基类的指针或引用绑定到派生类对象上
  和内置指针一样,智能指针类也支持派生类向基类的类型转换,这意味着可以将一个派生类对象的指针存储在一个基类的智能指针内。

静态类型与动态类型
  当使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型与该表达式表示对象的动态类型区分开来。表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型;动态类型则是变量或表达式表示的内存中的对象的类型,动态类型直到运行时才可知。
注意:基类的指针或引用的静态类型可能与其他动态类型不一致

不存在从基类向派生类的隐式类型转换…
  之所以存在派生类向基类的类型转换是因为每个派生类对象都包含一个基类部分,而基类的引用或指针可以绑定到该基类部分上。如果基类对象不是派生类对象的一部分,则它只含有基类定义的成员,而不含有派生类定义的成员。

…在对象之间不存在类型转换
关键概念:遵循基类的接口
  存在继承关系的类型之间的转换规则:

  • 从派生类向基类的类型转换只对指针或引用类型有效
  • 基类向派生类不存在隐式类型转换
  • 和任何其他成员一样,派生类向基类的类型转换也可能会由于访问受限变得不可行。

3 虚函数

关键概念:C++的多态性
  引用或指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在。如果该函数是虚函数,直到运行时才会决定到底执行哪个版本,判断的依据是引用或指针所绑定的对象的真实类型。
  当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同。

派生类中的虚函数
  一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类函数完全一致。

虚函数与默认实参
  若通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此,此时,传入派生类函数的将是基类函数定义的默认实参。

回避虚函数的机制
  在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本,使用作用域运算符可实现这一目的,示例如下:

//强行调用基类中定义的函数版本而不管baseP的动态类型到底是什么
double undiscounted = baseP->Quote::net_price(42);

tip:通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制

4 抽象基类

纯虚函数
纯虚函数从而令程序实现设计意图,和普通虚函数不一样,一个纯虚函数无须定义。通过在函数体的位置(即在声明语句的分号之前)书写=0就可以将一个虚函数说明为纯虚函数。其中,=0只能出现在类内部的虚函数声明语句处,示例如下:

class Disc_quote : public Quote {
public:
    Disc_quote() = default;
    Disc_quote(const std::string& book, double price,
              std::size_t qty, double disc):
                  Quote(book, price),
                  quantity(qty), discount(disc) {}
    double net_price(std::size_t) const = 0;
protected:
    std::size_t quantity = 0;    //折扣适用的购买量
    double discount = 0.0;    //表示折扣的小数值
    
};

含有纯虚函数的类是抽象基类
  含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类。抽象基类负责定义接口,而后续的其他类可以覆盖该接口。不能创建抽象基类的对象

派生类构造函数只初始化它的直接基类
关键概念:重构
  在Quote的继承体系中增加Disc_quote类是重构的一个典型示例,重构负责重新设计类的体系以便将操作和/或数据从一个类移动到另一个类中。

5 访问控制与继承

受保护的成员
protected说明符:

  • 和私有成员类似,受保护的成员对于类的用户来说是不可访问的
  • 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的
  • 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员,派生类对于一个基类对象中的受保护成员没有任何访问特权。
    示例如下:
class Base {
protected:
    int prot_mem;    //protected成员
};
class Sneaky : public Base
{
    friend void clobber(Sneaky&);    //能够访问Sneaky::prot_mem
    friend void clobber(Base&);    //不能访问Base::prot_mem
    int j;    //j默认是private
    
};
//正确:clobber能够访问Sneaky对象的private和protected成员
void clobber(Sneaky& s) { s.j = s.prot_mem = 0; }
//错误:clobber不能访问Base的protected成员
void clobber(Sneaky& b) { b.prot_mem = 0;}

公有、私有和受保护继承
  某个类对其继承而来的成员的访问权限受到两个因素影响:一是在基类中该成员的访问说明符,二是在派生类的派生列表中的访问说明符。示例如下:

class Base {
public:
    void pub_mem();    //public成员
protected:
    int prot_mem();    //protected成员
private:
    char priv_mem();    //private成员
};

struct Pub_Derv : public Base {
    //正确:派生类能访问protected
    int f() { return prot_mem; }
    //错误:private成员对于派生类来说是不可访问的
    char g() { return priv_mem; }
};
struct Priv_Derv : private Base {
    //private不影响派生类的访问权限
    int f1() const { return prot_mem; }
}

  派生类访问说明符的目的是控制派生类(包括派生类的派生类在内)对于基类成员的访问权限:

Pub_Derv d1;    //继承自Base的成员是public的
Priv_Derv d2;    //继承自Base的成员是private的
d1.pub_mem();    //正确:pub_mem在派生类中是public的
d2.pub_mem();    //错误:pub_mem在派生类中是private的

  派生类访问说明符还可以控制继承自派生类的新类的访问权限:

struct Derived_from_Public : public Pub_Derv {
    //正确:Base::prot_mem在Pub_Derv中仍然是protected的
    int use_base() { return prot_mem; }
};
struct Derived_from_Private : public Priv_Derv {
    //错误:Base::prot_mem在Priv_Derv中仍然是private的
    int use_base() { return prot_mem; }
}

派生类向基类转换的可访问性
关键概念:类的设计与受保护的成员
  一个类有两种不同的用户:普通用户和类的实现者。其中普通用户编写的代码使用类的对象,这部分代码只能访问类的公有(接口)成员;
实现者则负责编写类的成员和友元的代码,成员和友元既能访问类的公有部分,也能访问类的私有(实现)部分。
  对于派生类,基类把它希望派生类能够使用的部分声明成受保护的。普通用户不能访问受保护的成员,而派生类及其友元仍旧不能访问私有成员。
  因此基类应该将其接口成员声明为公有的;同时将属于其实现的部分分两组:一组可供派生类访问,这组应声明为受保护的;另一组只能由基类及基类的友元访问,这组应声明为私有

改变个别成员的可访问性
  有时需要改变派生类继承的某个名字的访问级别,通过使用using声明可以达到这一目的,示例如下:

class Base {
public:
    std::size_t size() const { return n;}
protected:
    std::size_t n;
}

class Derived : private Base{    //注意:private
public:
    //保持对象尺寸相关的成员的访问级别
    using Base::size;
protected:
    using Base::n;
}

如果一条using声明语句出现在类的private部分,则该名字只能被类的成员和友元访问;若using声明语句位于public部分,则类的所有用户都能访问它;若using声明语句位于protected部分,则该名字对于成员、友元和派生类是可访问的
派生类只能为那些它可以访问的名字提供using声明

默认的继承保护级别
  前面提到使用struct和class关键字定义的类具有不同的默认访问说明符。使用class关键字定义的派生类是私有继承的;使用struct关键字定义的派生类是公有继承的:

class Base { /*...*/ };
struct D1 : Base { /*...*/ };    //默认public继承
class D2 : Base { /*...*/ };    //默认private继承

6 继承中类作用域

名字冲突与继承
  和其他作用域一样,派生类也能重用定义在其直接基类或间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字,示例如下:

struct Base {
    Base(): mem(0) {}
protected:
    int mem;
};

stuct Derived : Base {
    Derived(int i): mem(i) {}    //用i初始化Derived::mem
                                 //Base::meme进行默认初始化
    int get_mem() {return mem; }    //返回Derived::mem
protected:
    int mem;    //隐藏基类中的mem
}

get_mem中mem引用的解析结果是定义在derived中的名字,示例如下:

Derived d(42);
cout << d.get_mem() << endl;    //打印42

派生类成员将隐藏同名的基类成员

通过作用域运算符来使用隐藏的成员
  通过作用域运算符来使用一个被隐藏的基类成员:

struct Derived : Base {\
    int get_base_mem() { return Base::mem; }
}

作用域运算符将覆盖掉原有的查找规则,并指示编译器从Base类的作用域开始查找mem。
关键概念:名字查找与继承
  现假设调用p->mem()(或者obj.mem()),依次执行以下4个步骤:

  • 首先确定p(或obj)的静态类型,因为调用的是一个成员,所以该类型必须是类类型。
  • 在p(或obj)的静态类型对应的类中查找mem,若找不到则依次在直接基类中不断查找直至到达继承链的顶端
  • 一旦找到了mem,就进行常规的类型检查,以确认对于当前找到的mem,本次调用是否合法
  • 假设调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码:
    • 若mem是虚函数且是通过引用或指针进行的调用,则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本。
    • 反之,编译器将产生一个常规函数调用。

虚函数与作用域
  假如基类与派生类的虚函数接受的实参不同,则无法通过基类的引用或指针调用派生类的虚函数,示例如下:

class Base {
public:
    virtual int fcn();
};

class D1 : public Base {
    public:
    //隐藏基类的fcn,这个fcn不是虚函数
    //D1继承了Base::fcn()的定义
    int fcn(int);    //形参列表与Base中的fcn不一致
    virtual void f2();    //是一个新的虚函数,在Base中不存在
};

class D2 : public D1 {
    public:
    int fcn(int);    //是一个非虚函数,隐藏了D1::fcn(int)
    int fcn();    //覆盖了Base的虚函数fcn
    void f2();    //覆盖了D1的虚函数f2
}

通过基类调用隐藏的虚函数
  给定上面定义的这些类后,下面来看几种使用期函数的方法:

Base bobj; D1 d1obj; D2 d2obj;
Base *bp1 = &bobj; D1 *bp2 = d1obj; D2 *bp3 = d2obj;
bp1->fcn();    //虚调用,将在运行时调用Base::fcn
bp2->fcn();    //虚调用,将在运行时调用Base::fcn
bp3->fcn();    //虚调用,将在运行时调用D2::fcn

D1 *d1p = &bobj; D2 *d2p = d2obj;
bp2->f2();    //错误,Base没有名为f2的成员
d1p->f2();    //虚调用,将在运行时调用D1::f2()
d2p->f2();    //虚调用,将在运行时调用D2::f2()

覆盖重载的函数
  和其他函数一样,成员函数无论是否是虚函数都能被重载。派生类可以覆盖重载函数的0个或多个实例。通过为重载的成员提供一条using声明语句,指定一个名字而不指定形参列表,所以一条基类成员函数的using声明语句可以把该函数的使用重载实例添加到派生类作用域中。

7 构造函数与拷贝控制

7.1 虚析构函数

  当我们delete一个动态分配的对象的指针时将执行析构函数,若该指针指向继承体系中的某个类型,则可能出现指针的静态类型与被删除对象的动态类型不符的情况。此时可以通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本,示例如下:

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

虚析构函数将阻止合成移动操作
  基类需要一个虚析构函数这一事实还会对基类和派生类的定义产生另外一个间接的影响:若一个类定义了析构函数,即使它通过default的形式使用了合成的版本,编译器也不会为这个类合成移动操作。

7.2 合成拷贝控制与继承

  基类或派生类的合成拷贝成员的行为与其他合成的构造函数、赋值运算符或析构函数类似:它们对类本身的成员依次进行初始化、赋值或销毁的操作。此外,还有一些其他操作,例如:

  • 合成的Bulk_quote默认构造函数运行Disc_quote的构造函数,后者又运行Quote的默认构造函数。
  • Quote的默认构造函数将bookNo成员默认初始化为空字符串,同时使用类内初始值将price初始化为0。
  • Quote的构造函数完成后,继续执行Disc_quote的构造函数,它使用类内初始值初始化qty和discount。

派生类中删除的拷贝控制与基类的关系
  某些定义基类的方式也可能导致有的派生类成员成为被删除的函数:

  • 若基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或者不可访问,则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作。
  • 若在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分。
  • 编译器将不会合成一个删除掉的移动操作。
    示例如下:
class B {
public:
    B();
    B(const B&) = delete;
}class D : public B {
    //没有声明任何构造函数
};

D d;    //正确:D的合成默认构造函数使用B的默认构造函数
D d2(d);    //错误:D的合成拷贝构造函数是被删除的
D d3(std::move(d));    //错误:隐式地使用D的被删除的拷贝构造函数

移动操作与继承
  因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以当确实需要执行移动操作是应该在基类在进行定义,示例如下:

class Quote {
 public:
    Quote() = default;    //对成员依次进行默认初始化
    Quote(const Quote&) = default;    //对成员依次拷贝
    Quote(Quote&&) = default;    //对成员依次拷贝
    Quote& operator=(const Quote&) = default;    //拷贝赋值
    Quote& operator=(Quote&&) = default;    //移动赋值
    virtual ~Quote() = default;    
};

7.3 派生类的拷贝控制成员

定义派生类的拷贝或移动构造函数
当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。
  当为派生类定义拷贝和移动构造函数时,通常使用对应的基类构造函数初始化对象的基类部分,示例如下:

class Base { /*...*/};
class D: public Base {
public:
    //
    //
    //显示地调用该构造函数
    D(const D& d):Base(d)    //拷贝基类成员
    /* D的成员的初始值 */ {/*...*/}
    D(D&& d):Base(std::move(d))    //移动基类成员
    /* D的成员的初始值 */ {/*...*/}
}

在默认情况下,基类默认构造函数初始化派生类对象的基类部分。若想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显示地使用基类的拷贝(或移动)构造函数。

派生类赋值运算符
  与拷贝和移动构造函数一样,派生类的赋值运算符也必须显式地为其他基类部分赋值P:

//Base::operator=(const Base&)不会被自动调用
D &D::operator=(const D &rhs)
{
    Base::operator=(rhs);    //为基类部分赋值
    return *this;
}

派生类析构函数
  在析构函数体执行完成后,对象的成员会被隐式销毁,类似的,对象的基类部分也是隐式销毁的。因此和析构函数及赋值运算符不同的是,派生类析构函数只负责销毁由派生类自己分配的资源:

class D : public Base {
public:
    //Base::~Base被自动调用执行
    ~D() { /*这里由用户定义清除派生类成员的操作*/}
};

  对象销毁的顺序正好与其创建的顺序相反:派生类析构函数首先执行,然后是基类的析构函数,以此类推,沿着继承体系的反方向直到最后。

7.4 继承的构造函数

  一个类只初始化它的直接基类,出于同样的原因,一个类也只继承其直接基类的构造函数,类不能继承默认、拷贝和移动构造函数。
  派生类继承基类构造函数的方式是提供了一条注明了(直接)基类名的using声明语句,示例如下:

class Bulk_quote : public Disc_quote {
public:
    using Disc_quote:Disc_quote;    //继承Disc_quote的构造函数
    double net_price(std::size_t) const;
}

通常情况下,using声明语句只是令某个名字在当前作用域内可见。

继承的构造函数的特点
  和普通构造函数不一样,一个构造函数的using声明不会改变该构造函数的访问级别,而且一个using声明语句不能指定explicitconstexpr,若基类的构造函数是explicit或者constexpr,则继承的构造函数也拥有相同的属性。

8 容器与继承

  
在容器中放置(智能)指针而非对象
  当希望在容器中存放具有继承关系的对象时,实际上存放的是基类的指针。和往常一样,这些症状所指对象的动态类型可能是基类类型,也可能是派生类类型,示例如下:

vector<shared_ptr<Quote>> basket;
basket.push_back(make_shared<Quote>("0-201-82470-1", 50));
basket.push_back(make_shared<Bulk_quote>("0-201-54848-8", 50, 10, .25));
//调用Quote定义的版本
cout << basket.back()->net_price(15)<< endl;

猜你喜欢

转载自blog.csdn.net/qq_18150255/article/details/89914679