第13章 類繼承
阿新 • • 發佈:2018-11-29
本章內容包括:
- is-a關係的繼承
- 如何以公有方式從一個類派生出另一個類
- 保護訪問
- 建構函式成員初始化列表
- 向上和向下強制轉換
- 虛成員函式
- 早期(靜態)聯編與晚期(動態)聯編
- 抽象基類
- 純虛擬函式
- 何時及如何使用公有繼承
面向物件程式設計的主要目的之一是提供可重用的程式碼.目前,很多廠商提供了類庫,類庫由類宣告和實現構成.因為類組合了資料表示和類方法,因此提供了比函式庫更加完整的程式包.
13.1 一個簡單的基類
- 程式清單13.1 tabtenn0.h
- 程式清單13.2 tabtenn0.cpp
- 程式清單13.3 usett0.cpp
13.1.1 派生一個類
- 使用公有派生,基類的公有成員將稱為派生類的公有成員.基類的私有部分也將稱為派生類的一部分,但只能通過基類的公有和保護方法訪問.
13.1.2 建構函式:訪問許可權的考慮
- 派生類不能直接訪問基類的私有成員,而必須通過基類方法進行訪問.具體地說,派生類建構函式必須使用基類建構函式.
- 建立派生類物件時,程式首先建立基類物件.從概念上說,這意味著基類物件應當在程式進入派生類建構函式之前被建立.C++使用成員初始化列表語法類完成這種工作.
- 派生類建構函式的要點如下: (很關鍵,要好好理解!!)
- 首先建立基類物件;
- 派生類建構函式應通過成員初始化列表將基類資訊傳遞給基類建構函式.
- 派生類建構函式應初始化派生類新增的資料成員
- 釋放物件的順序與建立物件的順序相反,即首先執行派生類的解構函式,然後自動呼叫基類的解構函式.
- 注意 : 建立派生類物件時,程式首先呼叫基類建構函式,然後再呼叫派生類建構函式.基類建構函式負責初始化繼承的資料成員;派生類建構函式主要用於初始化新增的資料成員.派生類的建構函式總是呼叫一個基類建構函式.可以使用初始化器列表語法指明要使用的基類建構函式,否則將使用預設的基類建構函式.派生類物件過期時,程式將首先呼叫派生類解構函式,然後在呼叫基類解構函式
- 成員初始化列表 : 派生類建構函式可以使用初始化器列表機制將值傳遞給基類建構函式.除虛基類外(參見第14章),類只能將值傳遞迴相鄰的基類,但後者可以使用相同的機制將資訊傳遞給相鄰的基類,一次類推.如果沒有在成員初始化列表中提供基類建構函式,程式將使用預設的基類建構函式.成員初始化列表只能用於建構函式.
13.1.3 使用派生類
- 程式清單13.4 tabtenn1.h
- 程式清單13.5 tabtenn1.cpp
- 程式清單13.6 usett1.cpp
13.1.4 派生類和基類之間的特殊關係
- 派生類物件可以使用基類的方法,條件是方法不是私有的.
- 基類指標可以在不進行顯式型別轉換的情況下指向派生類物件;
- 基類引用可以在不進行顯式型別轉換的情況下引用派生類物件.
- 然而,基類指標或引用只能用於呼叫基類方法.
- 通常,C++要求引用和指標型別與賦給的型別匹配,但這一規則對繼承來說是例外.然而,這種例外知識單向的,不可以將基類物件和地址賦給派生類引用和指標.
13.2 繼承:is-a關係
- C++有3種繼承方式 : 公有繼承,保護繼承和私有繼承.
- 因為派生類可以新增特性,所以,將這種關係稱為is-a-kind-of(是一種)關係可能更準確,但是通常使用術語is-a
- 公有繼承部監理has-a關係.
- 公有繼承不能建立is-like-a關係,也就是說,它不採用明喻.
- 公有繼承不建立is-implemented-as-a(作為…來實現)關係.
- 公有繼承不建立uses-a關係.
- 在C++中,完全可以使用公有繼承來建立has-a,is-implemented-as-a或uses-a關係,然而,這樣做通常會導致程式設計方面的問題.因此,還是堅持使用is-a關係吧.
13.3 多型公有繼承
- 方法的行為應取決於呼叫該方法的物件.這種較複雜的行為稱為多型—具有多種形態,即同一個方法的行為隨上下文而異.
- 有兩種重要的機制可用於實現多型公有繼承 :
- 在派生類中重新定義基類的方法
- 使用虛方法
13.3.1 開發Brass類和BrassPlus類
- 程式清單13.7 brass.h
- 關鍵字virtual.虛方法
- 如果方法是通過引用或指標而不是物件呼叫的,它將確定使用哪一種方法.如果沒有使用關鍵字virtual,程式將根據引用型別或指標型別選擇方法;如果使用了virtual,程式將根據引用或指標指向的物件的型別來選擇方法.
- 注意:如果要在派生類中重新定義基類的方法,通常應將基類方法宣告為虛的.這樣,程式將根據物件型別而不是應用或指標的型別來選擇方法版本.為基類宣告一個虛解構函式也是一種慣例.
- 關鍵字virtual.虛方法
- 1.類實現
- 關鍵字virtual只用於類宣告的方法原型中,而沒有用於類實現的方法定義中.
- 程式清單13.8 brass.cpp
- 派生類建構函式在初始化基類私有資料時,採用的是成員初始化列表語法.非建構函式不能使用成員初始化列表語法.
- 在派生類方法中,標準技術是使用作用域解析運算子來呼叫基類方法.
- 2.使用Brass和BrassPlus類
- 程式清單13.9 usebrass1.cpp
- 請注意為何Hogg受透支限制,而Pigg沒有
- 程式清單13.9 usebrass1.cpp
- 3.演示虛方法的行為
- 程式清單13.10 usebrass2.cpp
- 4.為何需要虛解構函式
- 使用虛解構函式可以確保正確的解構函式序列被呼叫.
13.4 靜態聯編和動態聯編
- 將原始碼中的函式呼叫解釋為執行耳釘的函式程式碼塊被稱為函式名聯編binding.C/C++比啊你起可以在編譯過程完成這種聯編.
- 在編譯過程中進行聯編被稱為靜態聯編static binding,又稱為早期聯編early binding.然而,虛擬函式使這項工作變得更困難.使用哪一個函式是不能在編譯時確定的,因為編譯器不知道使用者將選擇哪種型別的物件.所以,編譯器必須生成能夠在程式執行時選擇正確的虛方法的程式碼,這被稱為動態聯編dynamic binding,又稱為晚期聯編late binding.
13.4.1 指標和引用型別的相容性
- 在C++中,動態聯編與通過指標和引用呼叫方法相關,從某種程度上說,這是由繼承控制的.
- 將派生類應用或指標轉換為基類引用或指標被稱為向上強制轉換upcasting,這使公有繼承不需要進行顯式型別轉換.該規則是is-a關係的一部分.
- 相反的過程—將基類指標或引用轉換為派生類指標或引用—稱為向下強制轉換downcasting.如果不使用顯式型別轉換,則向下強制轉換是不允許的.
- 隱式向上強制轉換使基類指標或引用可以指向基類物件或派生類物件,因此需要動態聯編.C++使用虛成員函式來滿足這種需求.
13.4.2 虛成員函式和動態聯編
- 總之,編譯器對非虛方法使用靜態聯編.
- 總之,編譯器對虛方法使用動態聯編.
- 1.為什麼有兩種型別的聯編以及為什麼預設為靜態聯編
- 如果動態聯編讓您能夠重新定義類方法,而靜態聯編在這方面很差,為何不摒棄靜態聯編呢?原因有兩個—效率和概念模型
- 效率:Strousstrup說,C++的指導原則之一是,不要為不使用的特性付出代價(記憶體或者處理時間).僅當程式設計確實需要虛擬函式時,才使用它們.
- 概念模型:僅將那些預期將被重新定義的方法宣告為虛的.
- 提示:如果要在派生類中重新定義基類的方法,則將它設定為虛方法;否則,設定為非虛方法.
- 如果動態聯編讓您能夠重新定義類方法,而靜態聯編在這方面很差,為何不摒棄靜態聯編呢?原因有兩個—效率和概念模型
- 2.虛擬函式的工作原理
- C++規定了虛擬函式的行為,但將實現方法留給了編譯器作者.
- 通常,編譯器處理虛擬函式的方法是:給每個物件新增一個隱藏成員.隱藏成員中儲存了一個指向函式地址陣列的指標.這種陣列稱為虛擬函式表virtual function table ,vtbl.虛擬函式表中儲存了為類物件進行宣告的虛擬函式的地址.例如:基類物件包含一個指標,該指標指向基類中所有虛擬函式的地址表.派生類物件將包含一個指向獨立低指標的指標.如果派生類提供了虛擬函式的新定義,該虛擬函式表將儲存新函式的地址;如果派生類沒有重新定義虛擬函式,該vtbl將儲存函式原始版本的地址.如果派生類定義了新的虛擬函式,則該函式的地址也將被新增到vtbl中.注意,無論類中包含的虛擬函式是1個還是10個,都只需要在物件中新增1個地址成員,只是表的大小不同而已.呼叫虛擬函式時,程式將檢視儲存在物件中的vtbl地址,然後專項相應的函式地址表.如果使用類宣告中定義的第一個虛擬函式,則程式將使用陣列彙總的第一個函式地址,並執行具有該地址的函式.如果使用類宣告中第三個虛擬函式,程式將使用地址為陣列中第三個元素的函式.
- 總之,使用虛擬函式時,在記憶體和執行速度方面有一定的成本,包括:
- 每個物件都將增大,增大量為儲存地址的空間.
- 對於每個類,編譯器都建立一個虛擬函式地址表(陣列);
- 對於每個函式呼叫,都需要執行一項額外的操作,即到表中查詢地址.
13.4.3 有關虛擬函式注意事項
- 建構函式
- 建構函式不能是虛擬函式.將類建構函式宣告為虛的沒什麼意義.
- 解構函式
- 解構函式應當是虛擬函式,除非類不用做基類.
- 提示:通常應給基類提供一個虛解構函式,即使它並不需要解構函式.
- 友元
- 友元不能是虛擬函式,因為友元不是類成員,而只有成員才能是虛擬函式.
- 如果由於這個原因引起了設計問題,可以通過讓友元函式使用虛擬函式成員函式來解決.
- 沒有重新定義
- 如果派生類沒有重新定義函式,將使用該函式的基類版本.如果派生類位於派生鏈中,則將使用最新的虛擬函式版本,例外的情況是基類版本是隱藏的(稍後將介紹).
- 重新定義將隱藏方法.
- 總之,重新定義繼承的方法並不是過載.如果在派生類中重新定義函式,將不是使用相同的函式特徵標覆蓋基類宣告,而是隱藏同名的基類方法,不管引數特徵標如何.
- 這引出兩條經驗規則:第一,如果重新定義繼承的方法,應確保與原來的原型完全相同,但如果返回型別是基類引用或指標,則可以修改為指向派生類的引用或指標(這種例外是新出現的).這種特性被稱為返回型別協變covariance of return type),因為允許返回型別隨類型別的變化而變化.注意這種例外只適用於返回值,而不適用於引數.第二,如果基類宣告被過載了,則應在派生類中重新定義所有的基類版本.
13.5 訪問控制:protected
- private和protected之間的區別只有在基類派生的類中才會表現出來.
- 警告:最好對資料成員採用私有訪問控制,不要適用保護訪問控制;同時通過基類方法使派生類能夠訪問基類資料.
13.6 抽象基類
- 抽象基類abstract base class,ABC
- C++通過使用純虛擬函式pure virtual function提供未實現的函式.純虛擬函式宣告的結尾處為=0.當類宣告中包含純虛擬函式時,則不能建立該類的物件.
- 這裡的理念是,包含純虛擬函式的類只用作基類.要稱為真正的ABC,必須至少包含一個純虛擬函式.原型中的=0使虛擬函式稱為純虛擬函式.
13.6.1 應用ABC概念
- 程式清單13.11 acctabc.h
- 程式清單13.12 acctabc.cpp
- 程式清單13.13 usebrass3.cpp
13.6.2 ABC理念
- 設計ABC之前,首先應開發一個模型—指出變成問題所需的類以及它們之間相互關係.一種學院派思想認為,如果要設計類繼承層次,則只能將哪些不會被用作基類的類設計為具體的類.這種方法的設計更清晰,複雜程度更低.
13.7 繼承和動態記憶體分配
13.7.1 第一種情況:派生類不使用new
- 如果派生類未包含其他一些不常用的,需要特殊處理的設計特性,是否需要為派生類定義顯式解構函式,複製建構函式和賦值運算子呢?不需要.(此內容是擇選拼湊的)
13.7.2 第二種情況:派生類使用new
- 在這種情況下,必須為派生類定義顯式解構函式,複製建構函式和賦值運算子.
- 總之,當基類和派生類都採用動態記憶體分配時,派生類的解構函式,複製建構函式,賦值運算子都必須使用相應的基類方法來處理基類元素.這種要求是通過三種不同的方式來滿足的.對於解構函式,這是自動完成的;對於建構函式,這是通過在初始化成員列表中呼叫基類的複製建構函式來完成的;如果不這樣做,將自動呼叫基類的預設建構函式.對於賦值運算子,這是通過使用作用域解析運算子顯式地呼叫基類的賦值運算子來完成的.
13.7.3 使用動態記憶體分配和友元的繼承示例
- 程式清單13.14 dma.h
// dma.h -- inheritance and dynamic memory allocation
#ifndef DMA_H_
#define DMA_H_
#include <iostream>
// Base Class Using DMA
class baseDMA
{
private:
char * label;
int rating;
public:
baseDMA(const char * l = "null", int r = 0);
baseDMA(const baseDMA & rs);
virtual ~baseDMA();
baseDMA & operator=(const baseDMA & rs);
friend std::ostream & operator<<(std::ostream & os,
const baseDMA & rs);
};
// derived class without DMA
// no destructor needed
// uses implicit copy constructor
// uses implicit assignment operator
class lacksDMA :public baseDMA
{
private:
enum { COL_LEN = 40};
char color[COL_LEN];
public:
lacksDMA(const char * c = "blank", const char * l = "null",
int r = 0);
lacksDMA(const char * c, const baseDMA & rs);
friend std::ostream & operator<<(std::ostream & os,
const lacksDMA & rs);
};
// derived class with DMA
class hasDMA :public baseDMA
{
private:
char * style;
public:
hasDMA(const char * s = "none", const char * l = "null",
int r = 0);
hasDMA(const char * s, const baseDMA & rs);
hasDMA(const hasDMA & hs);
~hasDMA();
hasDMA & operator=(const hasDMA & rs);
friend std::ostream & operator<<(std::ostream & os,
const hasDMA & rs);
};
#endif
- 程式清單13.15 dma.cpp
// dma.cpp --dma class methods
#include "dma.h"
#include <cstring>
// baseDMA methods
baseDMA::baseDMA(const char * l, int r)
{
label = new char[std::strlen(l) + 1];
std::strcpy(label, l);
rating = r;
}
baseDMA::baseDMA(const baseDMA & rs)
{
label = new char[std::strlen(rs.label) + 1];
std::strcpy(label, rs.label);
rating = rs.rating;
}
baseDMA::~baseDMA()
{
delete [] label;
}
baseDMA & baseDMA::operator=(const baseDMA & rs)
{
if (this == &rs)
return *this;
delete [] label;
label = new char[std::strlen(rs.label) + 1];
std::strcpy(label, rs.label);
rating = rs.rating;
return *this;
}
std::ostream & operator<<(std::ostream & os, const baseDMA & rs)
{
os << "Label: " << rs.label << std::endl;
os << "Rating: " << rs.rating << std::endl;
return os;
}
// lacksDMA methods
lacksDMA::lacksDMA(const char * c, const char * l, int r)
: baseDMA(l, r)
{
std::strncpy(color, c, 39);
color[39] = '\0';
}
lacksDMA::lacksDMA(const char * c, const baseDMA & rs)
: baseDMA(rs)
{
std::strncpy(color, c, COL_LEN - 1);
color[COL_LEN - 1] = '\0';
}
std::ostream & operator<<(std::ostream & os, const lacksDMA & ls)
{
os << (const baseDMA &) ls;
os << "Color: " << ls.color << std::endl;
return os;
}
// hasDMA methods
hasDMA::hasDMA(const char * s, const char * l, int r)
: baseDMA(l, r)
{
style = new char[std::strlen(s) + 1];
std::strcpy(style, s);
}
hasDMA::hasDMA(const char * s, const baseDMA & rs)
: baseDMA(rs)
{
style = new char[std::strlen(s) + 1];
std::strcpy(style, s);
}
hasDMA::hasDMA(const hasDMA & hs)
: baseDMA(hs) // invoke base class copy constructor
{
style = new char[std::strlen(hs.style) + 1];
std::strcpy(style, hs.style);
}
hasDMA::~hasDMA()
{
delete [] style;
}
hasDMA & hasDMA::operator=(const hasDMA & hs)
{
if (this == &hs)
return *this;
baseDMA::operator=(hs); // copy base portion
delete [] style; // prepare for new style
style = new char[std::strlen(hs.style) + 1];
std::strcpy(style, hs.style);
return *this;
}
std::ostream & operator<<(std::ostream & os, const hasDMA & hs)
{
os << (const baseDMA &) hs;
os << "Style: " << hs.style << std::endl;
return os;
}
- 程式清單13.16 usedma.cpp
// usedma.cpp -- inheritance, friends, and DMA
// compile with dma.cpp
#include <iostream>
#include "dma.h"
int main()
{
using std::cout;
using std::endl;
baseDMA shirt("Portabelly", 8);
lacksDMA balloon("red", "Blimpo", 4);
hasDMA map("Mercator", "Buffalo Keys", 5);
cout << "Displaying baseDMA object:\n";
cout << shirt << endl;
cout << "Displaying lacksDMA object:\n";
cout << balloon << endl;
cout << "Displaying hasDMA object:\n";
cout << map << endl;
lacksDMA balloon2(balloon);
cout << "Result of lacksDMA copy:\n";
cout << balloon2 << endl;
hasDMA map2;
map2 = map;
cout << "Result of hasDMA assignment:\n";
cout << map2 << endl;
// std::cin.get();
return 0;
}
13.8 類設計回顧
13.8.1 編譯器生成的成員函式
- 預設建構函式
- 預設建構函式要麼沒有引數,要麼所有的引數都有預設值.
- 最好提供一個顯式預設建構函式,將所有的類資料成員都初始化為合理的值.
- 複製建構函式
- 複製建構函式接收其所屬類的物件作為引數.
- 在下述情況下,將使用複製建構函式:
- 將新物件初始化為一個同類物件;
- 按值將物件傳遞給函式;
- 函式按值返回物件;
- 編譯器生成臨時物件.
- 如果程式沒有使用(顯式或隱式)複製建構函式,編譯器將提供原型,但不提供函式定義;否則,程式將定義一個執行成員初始化的複製建構函式.也就是說,新物件的每個成員都被初始化為原始物件相應成員的值.如果成員為類物件,則初始化該成員時,將使用相應類的複製建構函式.
- 在某些情況下,成員初始化是不合適的.例如,使用new初始化的成員指標通常要求執行深複製,或者類可能包含需要修改的靜態變數.在上述情況下,需要定義自己的複製建構函式.
- 賦值運算子
- 如果需要顯式定義複製建構函式,則基於相同的原因,也需要顯式定義賦值運算子.
- 編譯器不會生成將一種型別賦給另一種型別的賦值運算子.
13.8.2 其他的類方法
- 建構函式
- 解構函式
- 一定要定義顯式解構函式來釋放類建構函式使用new分配的所有記憶體,並完成類物件所需的任何特殊的清理工作.對於基類,即使它不需要解構函式,也應提供一個虛解構函式.
- 轉換
- 使用一個引數就可以呼叫的建構函式定義了從引數型別到類型別的轉換.將可轉換的型別傳遞給以類為引數的函式時,將呼叫轉換建構函式.
- 在帶一個引數的建構函式原型中使用explicit將禁止進行隱式轉換,但仍允許顯式轉換.
- 要將類物件轉換為其他型別,應定義轉換函式.
- 按值傳遞物件與傳遞引用
- 通常,編寫使用物件作為引數的函式時,應按引用而不是按值來傳遞物件.另一個原因是,在繼承使用虛擬函式時,被定義為接收基類引用引數的函式可以接受派生類.
- 返回物件和返回引用
- 首先,在編碼方面,直接返回物件與返回引用之間唯一的區別在於函式原型和函式頭.
- 其次,應返回引用而不是返回物件的原因在於,返回物件設計生成返回物件的臨時副本,這是呼叫函式的程式可以使用的副本.返回引用可節省時間和記憶體.
- 然而,並不總是可以返回引用.函式不能返回在函式中建立的臨時物件的引用,因為當函式結束時,臨時物件將消失,因此這種引用將是非法的.
- 通用的規則是,如果函式返回在函式中建立的臨時物件,則不要使用引用.如果函式返回的是通過引用或指標傳遞給它的物件,則應按引用返回物件.
- 使用const
- 使用const時應特別注意.可以用它來確保方法不修改引數.
- 可以使用const來確保方法修改呼叫它的物件.
- 通常,可以將返回引用的函式放在賦值語句的左側,這實際上意味著可以將值賦給引用的物件.但可以使用const來確保引用或指標返回的值不能用於修改物件中的資料.
13.8.3 公有繼承的考慮因素
- is-a關係
- 什麼不能被繼承
- 建構函式
- 解構函式
- 賦值運算子
- 賦值運算子
- 如果類建構函式使用new來初始化指標,則需要提供一個顯式賦值運算子.
- 私有成員與保護成員
- 虛方法
- 設計基類時,必須確定是否將類方法宣告為虛的.
- 解構函式
- 友元函式
- 由於友元函式並非類成員,因此不能繼承.
- 然而,您可能希望派生類的幽咽函式能夠使用基類的友元函式,為此,可以通過強制型別轉換將,派生類應用或指標轉換為基類引用或指標,然後使用轉換後的指標或引用來呼叫基類的友元函式.
- 有關使用基類方法的說明
13.8.4 類函式小結
- 表13.1(摘自《The annotated C++ Reference Manual》)
- C++類函式有很多不同的變體,其中有些可以繼承,有些不可以.有些運算子函式即可以是成員函式,也可以是友元,而有些運算子函式只能是成員函式.
13.9 總結
13.10 複習題
13.11 程式設計練習