在复杂的软件系统设计中,经常会面临对一组具有相似属性但在某些行为上存在差异的对象进行统一建模的需求。 例如,在图书管理系统中,各种类型的图书(如普通图书、针对学生的图书、批量采购的图书等)在价格计算和优惠政策等业务逻辑上各不相同。
此时,采用单一的图书类难以满足多样化业务场景的需求。通过合理运用面向对象编程(OOP)理念,借助继承、多态等机制,可以建立一套可扩展、可维护的类层次结构,实现对不同图书类型的统一管理和差异化处理,高效支撑系统的演化和复杂需求的实现。

面向对象编程的核心思想包含三个关键概念:数据抽象、继承和动态绑定。通过数据抽象,我们可以定义将接口与实现分离的类。通过继承,我们可以定义相似类型之间的关系。通过动态绑定,我们可以在忽略类型差异细节的情况下使用这些对象。
继承相关的类形成一个层次结构。通常在层次结构的根部有一个基类,其他类直接或间接地从它继承。这些继承的类被称为派生类。基类定义了层次结构中类型共同的成员,每个派生类定义特定于派生类本身的成员。
这些类将包含以下两个成员函数:
在C++中,基类将那些希望派生类自己定义的函数标记为虚函数。基类通过在函数声明前加上virtual关键字来指定虚函数。
|class Book { public: std::string getIsbn() const; virtual double calculatePrice(std::size_t quantity) const; };
派生类必须在类派生列表中指定它打算继承的类。类派生列表是一个冒号,后跟逗号分隔的基类列表,每个基类前面可以有一个可选的访问说明符:
|class StudentBook : public Book { public: double calculatePrice(std::size_t quantity) const override; };
由于StudentBook在其派生列表中使用了public,我们可以将StudentBook对象当作Book对象来使用。
通过动态绑定,我们可以使用相同的代码来处理Book或StudentBook类型的对象。下面这个函数演示了动态绑定的特点:
|double printTotal(std::ostream &output, const Book &item, size_t quantity) { double total = item.calculatePrice(quantity); output << "ISBN: " << item.getIsbn() << " 数量: " << quantity << " 总价: " << total << std::endl; return
这个函数相对简单,它调用getIsbn和calculatePrice函数并打印结果。有两个有趣的特点:由于参数item是Book的引用,我们可以用Book对象或StudentBook对象来调用这个函数。 由于calculatePrice是虚函数,并且printTotal通过引用调用calculatePrice,实际运行的版本将取决于传递给printTotal的对象的实际类型。
当我们创建不同类型的对象并调用这个函数时:
|Book ordinary("978-7-111-12345-6", 89.0); StudentBook discounted("978-7-111-54321-9", 89.0, 0.8); printTotal(std::cout, ordinary, 10); // 调用Book版本的calculatePrice printTotal(std::cout, discounted, 10); // 调用StudentBook版本的calculatePrice
第一个调用传递一个Book对象给printTotal。当printTotal调用calculatePrice时,将运行Book版本。在第二个调用中,参数是StudentBook对象,因此将运行StudentBook版本的calculatePrice。
动态绑定只有当虚函数通过指向基类的引用或指针调用时才会发生。
让我们完善Book类的定义:
|class Book { public: Book() = default; Book(const std::string &isbn, double price) : bookIsbn(isbn), bookPrice(price) { } std::string getIsbn() const { return bookIsbn; } virtual double calculatePrice(std::size_t quantity
这个类的新特点是在calculatePrice函数和析构函数上使用了virtual关键字,以及protected访问说明符的使用。基类通常应该定义一个虚析构函数,即使它不执行任何操作。
派生类继承其基类的成员。但是,派生类需要能够为那些依赖于类型的操作提供自己的定义,比如calculatePrice。在这种情况下,派生类需要覆盖从基类继承的定义。
基类必须将期望派生类覆盖的函数与期望派生类不加改变就继承的函数区分开来。基类将期望派生类自己定义的函数定义为虚函数。 当我们通过指针或引用调用虚函数时,调用将被动态绑定。根据引用或指针所绑定的对象类型,调用基类中的版本或某个派生类中的版本。
非虚函数在编译时解析,不是运行时。对于getIsbn成员,这正是我们想要的行为。getIsbn函数不依赖于派生类型的细节,在Book或StudentBook对象上运行时行为相同。
派生类继承定义在基类中的成员。但是,派生类的成员函数不一定有权访问从基类继承的成员。像任何其他使用基类的代码一样,派生类能访问基类的公有成员,但不能访问私有成员。
有时基类中有一些成员,希望派生类能够访问,但又禁止其他用户访问。我们用protected访问说明符来说明这样的成员。
在我们的Book类中,派生类需要访问bookPrice成员来定义自己的calculatePrice函数。因此,Book将该成员定义为protected。派生类访问bookIsbn的方式与普通用户相同,即通过调用getIsbn函数。因此,bookIsbn成员是private的,派生类不能访问它。
派生类必须在其类派生列表中指定从哪个类继承。让我们完善StudentBook类:
|class StudentBook : public Book { public: StudentBook() = default; StudentBook(const std::string &isbn, double price, double discount) : Book(isbn, price), discountRate(discount) { } double calculatePrice(std::size_t quantity) const
StudentBook类继承了Book的getIsbn函数以及bookIsbn和bookPrice数据成员。它定义了自己的calculatePrice版本,并有一个额外的数据成员discountRate。
派生类经常(但不总是)覆盖它们继承的虚函数。如果派生类没有覆盖其基类中的某个虚函数,那么,像任何其他成员一样,派生类会继承其基类中的版本。
派生类可以在它覆盖的函数前使用virtual关键字,但这不是必需的。新标准允许派生类显式地注明它将用哪个成员函数改写基类的虚函数,具体措施是在形参列表后面,或者在const成员函数的const关键字后面, 或者在引用成员函数的引用限定符后面添加一个关键字override。
派生类对象包含多个组成部分:一个含有派生类自己定义的非静态成员的子对象,以及一个与该派生类继承的基类对应的子对象。 例如,一个StudentBook对象包含四个数据元素:从Book继承的bookIsbn和bookPrice数据成员,以及StudentBook自己定义的discountRate成员。
因为派生类对象包含与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用。特别地,我们能将基类的指针或引用绑定到派生类对象的基类部分上:
|Book item; StudentBook studentItem; Book *bookPtr = &item; bookPtr = &studentItem; // bookPtr指向studentItem的Book部分 Book &bookRef = studentItem; // bookRef绑定到studentItem的Book部分
这种转换通常称为派生类到基类的转换。编译器会隐式地执行派生类到基类的转换。
派生类对象包含从基类继承的成员,但派生类不能直接初始化这些成员。派生类必须使用基类的构造函数来初始化它的基类部分。
每个类控制它自己的成员初始化过程。基类部分的初始化和派生类数据成员的初始化都是在构造函数的初始化阶段执行的。 类似于初始化成员的方式,派生类构造函数同样是通过构造函数初始化列表来将实参传递给基类构造函数的:
|StudentBook::StudentBook(const std::string &isbn, double price, double discount) : Book(isbn, price), discountRate(discount) { }
这个构造函数将前两个参数传递给Book构造函数,Book构造函数初始化StudentBook对象的基类部分(即bookIsbn和bookPrice成员)。 当Book构造函数完成后,直接成员discountRate被初始化。最后,执行StudentBook构造函数的函数体。
派生类可以访问基类的公有成员和受保护成员:
|double StudentBook::calculatePrice(size_t quantity) const { if (quantity >= 10) // 批量购买享受额外折扣 return quantity * bookPrice * discountRate * 0.9; else return quantity * bookPrice * discountRate; }
这个函数根据购买数量决定是否给予额外折扣。如果购买数量达到10本或以上,则在学生折扣基础上再打9折。
派生类的作用域嵌套在基类的作用域内。因此,对于派生类成员来说,使用基类中定义的成员和使用派生类自己定义的成员没有什么区别。
正如我们所见,在C++中动态绑定发生在通过基类的引用或指针调用虚成员函数时。因为直到运行时才能知道调用哪个版本的函数,所以虚函数必须始终被定义。
当通过引用或指针调用虚函数时,编译器产生的代码直到运行时才能确定应该调用哪个函数版本。被调用的函数是与绑定到指针或引用上的对象的动态类型相对应的那一个。
让我们用前面的printTotal函数来举例说明。该函数在名为item的参数上调用calculatePrice,item的类型是Book&。 因为item是引用,而且calculatePrice是虚函数,所以在运行时才会决定调用calculatePrice的哪个版本,决定的依据是传递给printTotal的实参的实际类型:
|Book regularBook("978-7-111-11111-1", 45.0); printTotal(std::cout, regularBook, 20); // 调用Book::calculatePrice StudentBook discountBook("978-7-111-22222-2", 45.0, 0.8); printTotal(std::cout, discountBook, 20); // 调用StudentBook::calculatePrice
在第一个调用中,item绑定到Book对象。因此,当printTotal调用calculatePrice时,运行的是Book中定义的版本。在第二个调用中,item绑定到StudentBook对象,此时调用的是StudentBook版本的calculatePrice。
动态绑定只有当通过指针或引用调用虚函数时才会发生。当我们通过一个具有普通类型(非指针也非引用)的表达式调用虚函数时,在编译时就会将调用的版本确定下来:
|regularBook = discountBook; // 拷贝discountBook的Book部分给regularBook regularBook.calculatePrice(20); // 调用Book::calculatePrice
当我们对regularBook调用虚函数时,无论regularBook的内容是什么,都不会改变该对象的类型。因此这个调用在编译时就解析为Book版本的calculatePrice。
当在派生类中覆盖了某个虚函数时,可以再一次使用virtual关键字指出该函数的性质,但这样做并非必需。一旦某个函数被声明成虚函数,则在所有派生类中它都是虚函数。
派生类中覆盖某个虚函数时,该函数在基类中的形参必须与派生类中的形参严格匹配。除了一种例外情况,派生类中虚函数的返回类型也必须与基类函数匹配。例外情况是当类的虚函数返回类型是类本身的指针或引用时。
新标准允许派生类显式地注明哪个成员函数改写基类的虚函数,具体做法是在形参列表后面添加override关键字:
|struct Vehicle { virtual void start(int mode) const; virtual void stop(); void refuel(); }; struct Car : Vehicle { void start(int mode) const override; // 正确 void stop(int) override; // 错误:Vehicle没有接受int参数的stop函数
如果我们使用override标记了某个函数,但该函数并没有覆盖已存在的虚函数,此时编译器将报错。
我们还可以把某个函数指定为final,如果我们已经把函数定义成final了,则之后任何尝试覆盖该函数的操作都将引发错误:
|struct BaseVehicle { virtual void start(int mode) const final; // 不允许后续的派生类覆盖start }; struct DerivedVehicle : BaseVehicle { void start(int mode) const; // 错误:不能覆盖final函数 };
如果虚函数使用默认参数,则基类和派生类中定义的默认参数最好一致。如果通过基类的引用或指针调用函数,则使用基类中定义的默认参数,即使实际运行的是派生类中的函数版本。
在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的:
|double undiscountedPrice = bookPtr->Book::calculatePrice(42);
该代码强行调用Book的calculatePrice版本,无论bookPtr实际指向什么类型的对象。
通常情况下,只有成员函数中的代码才需要使用作用域运算符来回避虚函数的机制。什么时候我们需要回避虚函数的默认机制呢?通常是当一个派生类的虚函数调用它覆盖的基类的虚函数版本时。 在这种情况下,基类的版本通常完成继承层次中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身有关的操作。
假设我们想要扩展图书类以支持多种折扣策略。除了学生折扣外,我们可能还要提供会员折扣、批量折扣等。每种折扣策略都需要一个数量和一个折扣数额。
我们可以定义一个名为DiscountBook的新类来存储数量和折扣数额。表示特定折扣策略的类(如StudentBook)将从DiscountBook继承。每个派生类通过定义自己的calculatePrice版本来实现其折扣策略。
在定义DiscountBook类之前,我们必须首先决定对calculatePrice做些什么。我们的DiscountBook类不对应任何特定的折扣策略,因此为这个类的calculatePrice函数赋予意义没有实际价值。
上图展示了一个更灵活的继承结构:Book作为基类,DiscountBook继承自Book并增加了折扣相关的数据成员, 而StudentBook、MemberBook、BulkBook等具体折扣策略类则继承自DiscountBook,并各自实现自己的calculatePrice函数。
我们可以将calculatePrice定义为纯虚函数。和普通的虚函数不一样,一个纯虚函数无需定义。 我们通过在函数体的位置(即在声明语句的分号之前)书写=0就可以将一个虚函数说明为纯虚函数:
|class DiscountBook : public Book { public: DiscountBook() = default; DiscountBook(const std::string &isbn, double price, std::size_t minQty, double discount) : Book(isbn, price), minQuantity(minQty), discountRate(discount) { } double calculatePrice
值得注意的是,我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。也就是说,我们不能在类的内部为一个=0的函数提供函数体。
含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类。抽象基类负责定义接口,而后续的其他类可以覆盖该接口。我们不能直接创建一个抽象基类的对象:
|DiscountBook discountItem; // 错误:不能定义DiscountBook的对象 StudentBook studentItem; // 正确:StudentBook没有纯虚函数
从DiscountBook继承的类必须定义calculatePrice函数,否则它们仍然是抽象基类。
现在我们可以重新实现StudentBook,让它继承DiscountBook而非直接继承Book:
|class StudentBook : public DiscountBook { public: StudentBook() = default; StudentBook(const std::string &isbn, double price, std::size_t minQty, double discount) : DiscountBook(isbn, price, minQty, discount) { } double calculatePrice(std::
这个版本的StudentBook有一个直接基类DiscountBook和一个间接基类Book。每个StudentBook对象包含三个子对象:一个(空的)StudentBook部分、一个DiscountBook子对象和一个Book子对象。
一个类使用protected关键字来声明那些它希望与派生类分享但是不想被其他公共访问使用的成员。protected说明符可以看做是private和public中和后的产物:
派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。
某个类对其继承而来的成员的访问权限受到两个因素影响:一是在基类中该成员的访问说明符,二是在派生类的派生列表中的访问说明符。
让我们看一个例子:
|class Document { public: void open(); protected: int fileSize; private: char* buffer; }; class TextDocument : public Document { int lineCount() { return fileSize; } // 正确:派生类可以访问受保护成员 char getChar() { return buffer[0]; } // 错误:私有成员不可访问 }; class PrivateDocument
派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没什么影响。对基类成员的访问权限只与基类中的访问说明符有关。
派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限:
|TextDocument publicDoc; PrivateDocument privateDoc; publicDoc.open(); // 正确:open在派生类中是public的 privateDoc.open(); // 错误:open在私有继承中是private的
派生类向基类的转换是否可访问由使用该转换的代码决定,同时也与派生类的派生访问说明符有关。假定D继承自B:
就像友元关系不能传递一样,友元关系同样也不能继承。基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员。
有时我们需要改变派生类继承的某个名字的访问级别,通过使用using声明可以达到这一目的:
|class PrivateDocument : private Document { public: using Document::fileSize; // 将fileSize的访问级别改为public protected: using Document::open; // 将open的访问级别改为protected };
通过在类的公有部分提供using声明,用户将能够访问fileSize。通过在受保护部分提供using声明,派生类的派生类将能访问open。
每个类定义自己的作用域,在继承关系中,派生类的作用域嵌套在其基类的作用域之内。如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。
正是由于这种层次化的嵌套关系,派生类才能够像使用自己的成员一样使用基类的成员。例如:
|StudentBook discountBook; std::cout << discountBook.getIsbn();
名字getIsbn的解析过程如下:首先在StudentBook中查找,没有找到该名字。因为StudentBook派生自DiscountBook,接下来在DiscountBook中查找,仍未找到。因为DiscountBook派生自Book,最后在Book中查找,找到了该名字,因此getIsbn被解析为Book中的getIsbn。
一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型与动态类型可能不一致(当使用基类的引用或指针时会发生这种情况),但静态类型仍然决定了我们能够使用哪些成员。
作为一个例子,我们可以为DiscountBook类添加一个返回购买策略的成员:
|class DiscountBook : public Book { public: std::pair<size_t, double> getDiscountPolicy() const { return {minQuantity, discountRate}; } // 其他成员同前 };
我们只能通过DiscountBook及其派生类的对象、指针或引用使用getDiscountPolicy:
|StudentBook studentItem; StudentBook *studentPtr = &studentItem; Book *bookPtr = &studentItem; studentPtr->getDiscountPolicy(); // 正确:studentPtr类型为StudentBook* bookPtr->getDiscountPolicy(); // 错误:bookPtr类型为Book*
尽管studentItem确实有一个名为getDiscountPolicy的成员,但该成员对于bookPtr来说是不可见的。bookPtr的类型是指向Book的指针,这意味着对getDiscountPolicy的搜索将从Book类开始。显然Book不包含名为getDiscountPolicy的成员,所以我们无法通过Book的指针调用getDiscountPolicy。
和其他作用域一样,派生类也能重用定义在其直接基类或间接基类中的名字,此时定义在内层作用域(即派生类)中的名字将隐藏定义在外层作用域(即基类)中的名字:
|struct BaseClass { BaseClass() : data(0) { } protected: int data; }; struct DerivedClass : BaseClass { DerivedClass(int value) : data(value) { } // 初始化DerivedClass::data int getData() { return data; } // 返回DerivedClass::data protected: int data; // 隐藏基类的data };
DerivedClass中的data成员隐藏了BaseClass的data成员。如果我们运行:
|DerivedClass derived(42); std::cout << derived.getData() << std::endl; // 输出42
getData返回的是DerivedClass中定义的data,而不是BaseClass中的data。
可以通过作用域运算符来使用一个被隐藏的基类成员:
|struct DerivedClass : BaseClass { int getBaseData() { return BaseClass::data; } // 其他成员同前 };
作用域运算符指示编译器从BaseClass开始查找data。
如前所述,声明在内层作用域的函数并不会重载声明在外层作用域的函数。因此,定义在派生类中的函数也不会重载其基类中的成员。和其他作用域一样,如果派生类(即内层作用域)的成员与基类(即外层作用域)的某个成员同名,则派生类将在其作用域内隐藏该基类成员。即使基类和派生类中的函数有不同的形参列表,基类成员也仍然会被隐藏掉:
|struct BaseClass { int method(); }; struct DerivedClass : BaseClass { int method(int); // 隐藏BaseClass::method }; DerivedClass derived; derived.method(); // 错误:无参数的method被隐藏了 derived.method(10); // 正确:调用DerivedClass::method derived.BaseClass::method(); // 正确:调用BaseClass::method
现在我们能理解为什么基类与派生类中的虚函数必须有相同的形参列表了。假如基类与派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或指针调用派生类的版本了。
让我们看一个例子:
|class Animal { public: virtual int move(); }; class Bird : public Animal { public: int move(int distance); // 隐藏Animal::move,这不是覆盖 virtual void fly(); // Bird的新虚函数 }; class Eagle : public Bird { public: int move
Bird中的move函数并没有覆盖Animal中的虚函数move,因为它们的形参列表不同。相反,它隐藏了基类的move。实际上,Bird有两个名为move的函数:一个继承自Animal的虚函数,它接受0个形参;一个自己定义的非虚函数,它接受一个int形参。

继承对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数。析构函数需要是虚的,这样我们就能动态分配继承体系中的对象了。
当我们delete一个动态分配的对象的指针时将运行析构函数。如果该指针指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除对象的动态类型不符的情况。例如,如果我们delete一个Book*类型的指针,则该指针有可能实际指向一个StudentBook对象。如果这样的话,编译器就必须清楚它应该执行的是StudentBook的析构函数。和其他函数一样,我们通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本:
|class Book { public: virtual ~Book() = default; // 虚析构函数 // 其他成员 };
像其他虚函数一样,析构函数的虚属性也会被继承。因此,无论Book的派生类使用合成的析构函数还是定义自己的析构函数,都将是虚析构函数。只要基类的析构函数是虚函数,就能确保当我们delete基类指针时将运行正确的析构函数版本:
|Book *bookPtr = new Book; delete bookPtr; // 调用Book的析构函数 bookPtr = new StudentBook; delete bookPtr; // 调用StudentBook的析构函数
如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为。
基类或派生类的合成拷贝控制成员的行为和其他合成的构造函数、赋值运算符或析构函数类似:它们对类本身的成员依次进行初始化、赋值或销毁的操作。此外,这些合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作。
例如,StudentBook的合成默认构造函数运行DiscountBook的默认构造函数,后者又运行Book的默认构造函数。Book的默认构造函数将bookIsbn初始化为空字符串,并使用类内初始化器将bookPrice初始化为0。当Book构造函数结束后,继续执行DiscountBook构造函数,它使用类内初始化器分别将minQuantity和discountRate初始化。当DiscountBook构造函数结束后,继续执行StudentBook构造函数,但是它没有任何其他工作需要做。
当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。
当为派生类定义拷贝或移动构造函数时,我们通常使用对应的基类构造函数初始化对象的基类部分:
|class StudentBook : public DiscountBook { public: StudentBook(const StudentBook& other) : DiscountBook(other) // 拷贝基类部分 { /* 拷贝StudentBook特有的成员 */ } StudentBook(StudentBook&& other) : DiscountBook(std::move(other)) // 移动基类部分 { /* 移动StudentBook特有的成员 */ }
初始化器DiscountBook(other)将一个StudentBook对象传递给DiscountBook拷贝构造函数。该DiscountBook构造函数负责拷贝other的DiscountBook部分给正在创建的对象;该DiscountBook构造函数还会调用Book的拷贝构造函数来拷贝other的Book部分。
如果我们没有提供基类的初始化器:
|// 可能不正确的定义 StudentBook(const StudentBook& other) /* 没有基类初始化器 */ { /* 成员初始化器,但没有基类初始化器 */ }
则DiscountBook的默认构造函数将被用来初始化StudentBook对象的基类部分。假定StudentBook的构造函数拷贝了other的StudentBook特有的成员,则这个新构造的对象将是一个奇怪的混合体:它的StudentBook成员从other拷贝而来,但是它的基类成员却是默认初始化的。
与拷贝和移动构造函数一样,派生类的赋值运算符也必须显式地为其基类部分赋值:
|StudentBook& StudentBook::operator=(const StudentBook &rightHandSide) { DiscountBook::operator=(rightHandSide); // 为基类部分赋值 // 为StudentBook的成员赋值 return *this; }
该运算符首先显式调用基类的赋值运算符来为基类部分赋值。基类的运算符(当然)会正确处理自赋值情况并根据需要释放左侧运算对象基类部分的旧值然后将右侧运算对象基类部分的值拷贝给左侧运算对象。一旦基类运算符执行完毕,我们继续为派生类的成员执行任何需要的赋值操作。
对象销毁的顺序正好与其创建的顺序相反:派生类析构函数首先执行,然后是基类的析构函数,以此类推,沿着继承体系的反方向直至最后。
|class StudentBook : public DiscountBook { public: ~StudentBook() { /* 清理StudentBook特有的资源 */ } // Book::~Book被自动调用 };
对象销毁的顺序正好与其创建的顺序相反:StudentBook析构函数首先运行,然后是DiscountBook析构函数,最后是Book析构函数。
在新标准下,派生类能够重用其直接基类定义的构造函数。尽管如我们将要看到的,这些构造函数并不是以常规的方式继承而来,但是为了方便,我们不妨姑且称之为"继承"的构造函数。一个类只初始化它的直接基类,出于同样的原因,一个类也只继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。
派生类继承基类构造函数的方式是提供一条注明了(直接)基类名的using声明语句。举个例子,我们可以重新定义我们的StudentBook类,令其继承DiscountBook的构造函数:
|class StudentBook : public DiscountBook { public: using DiscountBook::DiscountBook; // 继承DiscountBook的构造函数 double calculatePrice(std::size_t quantity) const override; };
通常情况下,using声明语句只是令某个名字在当前作用域内可见。而当作用于构造函数时,using声明语句将令编译器产生代码。对于基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。换句话说,对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。
这些编译器生成的构造函数形如:
|派生类名(形参列表) : 基类名(实参列表) { }
在我们的StudentBook类中,继承的构造函数等价于:
|StudentBook(const std::string &isbn, double price, std::size_t minQty, double discount) : DiscountBook(isbn, price, minQty, discount) { }
如果派生类含有自己的数据成员,则这些成员将被默认初始化。
当我们希望在容器中存放具有继承关系的对象时,我们通常必须采取间接存储的方式。我们无法直接将具有继承关系的多种类型的对象直接放在容器中,因为容器中的元素必须具有相同的类型。
当我们需要一个容器来存放具有继承关系的对象时,我们通常定义容器来存放基类的指针(最好是智能指针)。和往常一样,这些指针所指对象的动态类型可能是基类类型,也可能是派生类类型:
|std::vector<std::shared_ptr<Book>> basket; basket.push_back(std::make_shared<Book>("978-7-111-11111-1", 50)); basket.push_back(std::make_shared<StudentBook>("978-7-111-22222-2", 50, 10, 0.8)); // 调用Book版本定义的calculatePrice;打印500,即10 * 50 std
因为basket存放的是shared_ptr,所以我们必须解引用basket.back()的返回值以获得运行calculatePrice的对象。我们使用->来解引用并调用calculatePrice。和往常一样,实际调用的calculatePrice版本依赖于指针所指对象的动态类型。
值得注意的是,我们将basket定义为shared_ptr<Book>,但在第二个push_back中我们传递的是一个shared_ptr<StudentBook>对象。正如我们能将派生类的普通指针转换成基类指针一样,我们也能将指向派生类的智能指针转换为指向基类的智能指针。因此,make_shared<StudentBook>返回一个shared_ptr<StudentBook>对象,当我们调用push_back时该对象被转换为shared_ptr<Book>。
面向对象编程在C++中的一个矛盾之处是我们无法直接使用对象进行面向对象编程。相反,我们必须使用指针和引用。因为指针会增加程序的复杂性,所以我们经常定义一些辅助类来处理这种复杂性。
让我们从定义一个表示购物篮的类开始:
|class ShoppingBasket { public: void addItem(const std::shared_ptr<Book> &item) { items.insert(item); } double totalReceipt(std::ostream &output) const; private: static bool compare(const std::shared_ptr
我们的类使用multiset来保存交易记录,这样我们就能保存同一本书的多个记录,而且同一本书的所有记录会聚集在一起。
multiset的元素是shared_ptr,而shared_ptr没有定义小于运算符。因此,为了对元素进行排序,我们必须提供自己的比较操作。 在这里,我们定义一个私有静态成员compare,它比较shared_ptr所指向对象的ISBN。我们通过类内初始化器来初始化multiset,令其使用我们的compare函数。
ShoppingBasket类只定义了两个操作。我们已经在类内定义了addItem成员,该成员接受一个指向动态分配的Book的shared_ptr, 并将该shared_ptr放入multiset中。第二个成员totalReceipt打印购物篮中物品的清单并返回购物篮中所有物品的总价格:
|double ShoppingBasket::totalReceipt(std::ostream &output) const { double sum = 0.0; for (auto iter = items.cbegin(); iter != items.cend(); iter = items.upper_bound(*iter)) { sum += printTotal(output, **
我们的循环从定义并初始化iter开始,令其指向multiset中的第一个元素。条件检查iter是否等于items.cend()。 如果相等,则我们已经处理完了所有购买记录,循环终止。否则,我们处理下一本书。
这个循环的有趣之处在于我们递增iter的方式。此循环的目的不是处理容器中的每个元素,而是处理每个不同的ISBN。 我们通过调用upper_bound来跳过与当前关键字相同的所有元素。upper_bound返回一个迭代器,该迭代器指向最后一个具有相同关键字的元素之后的位置。 因此,我们得到的迭代器或者指向容器的末尾,或者指向下一本书。
在for循环中,我们调用printTotal来打印购物篮中每本书的细节:
|sum += printTotal(output, **iter, items.count(*iter));
printTotal的实参是一个用于写入的ostream、一个要处理的Book对象和一个计数。当我们解引用iter时,我们得到一个指向要打印的对象的shared_ptr。 为了得到那个对象,我们必须再次解引用shared_ptr。因此,**iter是一个Book对象(或者是从Book派生的类型的对象)。我们使用multiset的count成员来确定在multiset中有多少元素具有相同的关键字。
为了给ShoppingBasket类添加更友好的接口,我们需要解决一个问题:addItem函数接受shared_ptr参数,这意味着用户必须这样写:
|ShoppingBasket basket; basket.addItem(std::make_shared<Book>("123", 45)); basket.addItem(std::make_shared<StudentBook>("345", 45, 10, 0.8));
我们的下一步将重新定义addItem,使其接受Book对象而不是shared_ptr。新版本的addItem将处理内存分配,这样用户就不再需要这样做了。我们将定义两个版本,一个拷贝它的实参,另一个移动实参:
|void addItem(const Book& item); // 拷贝给定对象 void addItem(Book&& item); // 移动给定对象
唯一的问题是addItem不知道要分配什么类型的对象。当addItem进行内存分配时,它会拷贝(或移动)其item参数。某个地方会有一个诸如:
|new Book(item)
的表达式。不幸的是,这个表达式不能正常工作:new分配一个我们所要求类型的对象。这个表达式分配一个Book类型的对象并拷贝item的Book部分。但是,item可能指向一个StudentBook对象,在这种情况下,该对象会被切掉。
我们将通过给Book类添加一个虚成员来解决这个问题,该虚成员将分配当前对象的一份拷贝:
|class Book { public: virtual Book* clone() const & { return new Book(*this); } virtual Book* clone() && { return new Book(std::move(*this)); } // 其他成员同前 }; class StudentBook
因为我们有addItem的拷贝和移动版本,所以我们分别定义了clone的左值引用和右值引用版本。 每个clone函数分配当前类型的一个新对象。const左值引用成员将自己拷贝给新分配的对象;右值引用成员将自己的数据移动到新对象中。
使用clone,编写addItem的新版本很容易:
|class ShoppingBasket { public: void addItem(const Book& item) { items.insert(std::shared_ptr<Book>(item.clone())); } void addItem(Book&& item) { items.insert(std::shared_ptr<Book>(std::
和addItem本身一样,clone也是重载的,基于它是在左值还是右值上调用。因此addItem的第一个版本调用clone的const左值引用版本,第二个版本调用右值引用版本。 注意,在右值版本中,虽然item的类型是右值引用类型,但item(像任何其他变量一样)是左值。因此,我们调用move将右值引用绑定到item上。
我们的clone函数也是虚函数。无论运行的是Book版本还是StudentBook版本,都取决于item的动态类型。不管我们拷贝还是移动数据,clone都返回一个新分配的对象,该对象与调用clone的对象类型相同。
我们将shared_ptr绑定到该对象上并调用insert将这个新分配的对象添加到items中。注意,因为shared_ptr支持派生类向基类的转换,我们可以将shared_ptr<Book>绑定到StudentBook*上。
阅读以下代码,回答问题:
|class Vehicle { public: Vehicle(const string& brand) : brand_(brand) {} virtual void start() { cout << "Vehicle starting" << endl; } virtual void stop() = 0; // 纯虚函数 string getBrand() const { return brand_; } protected: string brand_; };
问题:
Vehicle* v = new Car("Toyota", 4); v->start();会输出什么?为什么?Vehicle类不能实例化,因为它包含纯虚函数stop() = 0。含有纯虚函数的类称为抽象类,不能创建对象,只能作为基类使用。
会输出"Car engine starting"。虽然v是Vehicle*类型,但它指向的是Car对象。由于start()是虚函数,会进行动态绑定,调用的是Car类中重写的start()方法,而不是基类中的版本。这就是多态性的体现。
虚函数的作用就是实现多态:通过基类指针调用虚函数时,会根据实际对象的类型来决定调用哪个版本的函数。
下面的代码有什么错误?如何修复?
|class Base { public: virtual void func(int x) { cout << "Base: " << x << endl; } }; class Derived : public Base { public: void func(double x) override { cout << "Derived: " << x << endl; } };
|#include <iostream> using namespace std; class Base { public: virtual void func(int x) { cout << "Base: " << x << endl; } }; class Derived : public Base { public: // 错误:参数类型不匹配,不能使用override // 修复:参数类型必须与基类完全一致 void func(int x
设计一个简单的形状类层次:创建抽象基类Shape,包含纯虚函数calculateArea(),然后实现派生类Circle(圆形)和Rectangle(矩形)。
|#include <iostream> #include <cmath> using namespace std; class Shape { public: virtual ~Shape() = default; // 虚析构函数 virtual double calculateArea() const = 0; // 纯虚函数 virtual void display() const = 0; }; class Circle
解释public、private、protected三种访问权限在继承中的区别。
|#include <iostream> using namespace std; class Base { public: int public_member = 1; // 公有成员:任何地方都可以访问 protected: int protected_member = 2; // 保护成员:基类和派生类可以访问,外部不能访问 private: int private_member = 3; // 私有成员:只有基类内部可以访问 }; class Derived : public
设计一个简单的员工管理系统:基类Employee包含姓名和基本工资,派生类FullTimeEmployee(全职员工)添加年终奖,实现计算工资的功能。
|#include <iostream> #include <string> #include <vector> #include <memory> using namespace std; class Employee { protected: string name_; double baseSalary_; public: Employee(const string& name, double baseSalary) : name_(name), baseSalary_(baseSalary) {}
错误原因:override关键字要求派生类中的函数签名必须与基类中的虚函数完全匹配。基类中func的参数是int,而派生类中是double,参数类型不匹配,所以不能使用override。
如果要重写虚函数,参数类型必须完全一致。如果参数类型不同,那就是函数重载,不是重写,不能使用override关键字。
这个例子展示了:
Shape包含纯虚函数,不能实例化Circle和Rectangle继承自Shape访问权限总结:
在继承中,protected成员允许派生类访问,但对外部隐藏,这是封装性的体现。
这个例子综合运用了:
FullTimeEmployee和PartTimeEmployee继承自EmployeecalculateSalary()在基类中声明为纯虚函数,强制派生类实现unique_ptr管理内存,避免内存泄漏