C++ 的 virtual function 提供我們相當便利的功能,我們可以依照傳入的指標類型”動態”地決定要執行哪一個版本的函式,並非在編譯時期就已經綁定好,這種功能稱為多型 (polymorphisom)。在深入介紹 virtual function 之前,我們先用一個簡單的例子展示多型的重要性。
為甚麼我們需要 virtual function?淺談類型轉換
假設我們要實現一個多類型形狀的程式碼,並且可以依照不同的形狀算出面積和周長。首先,我們可以將多邊形的基礎類型 Shape
先定義好,再讓不同的形狀類型繼承,而 Shape
包含 area()
、perimeter()
成員函數,以及 n
私有成員代表屬於 n
邊形。另外我們定義了一個 print_shape(Shape &shape)
函式,印出輸入物件 shape
的形狀類型、面積、周長。因為 Shape
並沒有具體代表任何形狀,只是這類類型的一個 prototype,因此我們將 Shape
的邊數、面積、周長都視為 -1
。
// Shape.h
class Shape
{
public:
Shape(): n(-1), name("NaN") {}
Shape(short n, std::string name);
~Shape() = default;
virtual float area();
virtual float perimeter();
friend void print_shape(std::ostream &os, Shape &shape);
protected:
short n;
std::string name;
};
void print_shape(std::ostream &os, Shape &shape);
// Shape.cpp
Shape::Shape(short n, std::string name)
{
this->n = n;
this->name = name;
}
float Shape::area()
{
return -1;
}
float Shape::perimeter()
{
return -1;
}
void print_shape(std::ostream &os, Shape &shape)
{
os << "Name: " << shape.name << std::endl;
os << "n: " << shape.n << std::endl;
os << "Area: " << shape.area() << std::endl;
os << "Perimeter: " << shape.perimeter() << std::endl;
}
接下來我們可以開始定義其他類型的形狀了,我們以長方形為例。很明顯的圓形和長方形繼承 Shape
之後,勢必要重寫(override) Shape::Area()
以及 Shape::Perimeter()
,才能輸出正確的面積以及周長,因此我們將這個類型定義為:
// Shape.h
class Rectangle: public Shape
{
public:
Rectangle() = default;
Rectangle(float w, float h, std::string name);
~Rectangle() = default;
float area();
float perimeter();
friend void print_shape(std::ostream &os, Shape &shape);
protected:
float w, h;
};
// Shape.cpp
Rectangle::Rectangle(float w, float h, std::string name): w(w), h(h), Shape(4, name) {}
float Rectangle::area()
{
return w*h;
}
float Rectangle::perimeter()
{
return (w + h)*2;
}
在主要函式裡我們宣告一個長方形,並印出此長方形的資訊。此時我們傳入的類型式 Rectangle
,但是接收的引用卻是 Shape
類型,因此會發生類型轉換。可以注意的是衍生類永遠可以被轉換成基類,因為只要將衍生類型多出來的部分省略掉,留下衍生類和基類共有的部分即可。在這個例子中,Shape
和 Rectalge
共有的成員有:area()
、perimeter()
、n
、name
,所以 print_total
的 shape
實際上只擷取 rect
裡面的那四個成員並加以操作。不幸的是在此例中,我們會發現印出來的資訊會和我們預想的不同,除了 n
和 name
以外,我們會看到 print_total
呼叫的是 Shape
類型裡的 area()
和 perimeter()
,而非傳入的 Rectangle
的。
#include "Shape.h"
#include <iostream>
int main()
{
Rectangle rect(3, 4, "3x4 rectangle");
print_shape(std::cout, rect);
/*
Name: 3x4 rectangle
n: 4
Area: -1
Perimeter: -1
*/
return 0;
}
從上述例子我們將類型轉換的規律整理出來,當 Rectangle
繼承 Shape
的類型,並且重寫 area()
、perimeter()
之後,Rectangle
其實保留了兩份 area()
、perimeter()
,一份是 Shape
的,一份是 Rectangle
的。當我們用一個基類引用或基類指針指向衍生類物件時,基類指針所對應到 area()
、perimeter()
其實是 Shape
版本的,而 Rectangle
的 n
沒有重寫,因此完整繼承 Shape
的 n
,所以他們兩個的 n
是相同的,print_shape
就會將 Rectangle
的 n
印出。
因此為了將正確的 Rectangle
資訊印出來,我們只能額外定義一個 print_rectangle(rectangle &r)
函式,用相同的類型承接輸入的物件。如果我們定義了其他形狀的類型,Circle
、Square
、Triangle
、Pentagon
、Hexagon
等等,那我們被迫需要定義出一系列對應的 print
函式。為了避免重複出現大量相同功能的函式被定義,virtual function 因而出現。
Virtual function 的機制與實例
當一個衍生類繼承並且定義了 virtual function
,再以另一個基類引用 (或是基類指標) 指向此衍生類之物件,並在執行程式時以這個基類引用呼叫 virtual function,系統會在 run time 的時候決定執行此衍生類的 virtual function,而非基類的 virtual function。為了更加容易理解 virtual function,我們延續形狀類型的例子,但是我們利用 virtual 宣告 area()
、perimeter()
函式。需要注意的是衍生類在重寫 virtual function 的時候,會在 function 的尾端加上 override
說明符,幫助告知編譯器此函式將要重寫某個父類的函式,其他 virtual function 的詳細規則,請見搞懂 Virtual Function 的使用規則。
class Shape
{
public:
Shape();
~Shape() = default;
virtual float area();
virtual float perimeter();
friend void print_shape(std::ostream &os, Shape &shape);
protected:
short n;
std::string name;
};
class Rectangle: public Shape
{
public:
Rectangle() = default;
Rectangle(float w, float h, std::string name);
~Rectangle() = default;
float area() override;
float perimeter() override;
friend void print_shape(std::ostream &os, Shape &shape);
protected:
float w, h;
};
#include "Shape.h"
#include <iostream>
int main()
{
Rectangle rect(3, 4, "3x4 rectangle");
print_shape(std::cout, rect);
/*
Name: 3x4 rectangle
n: 4
Area: 12
Perimeter: 14
*/
return 0;
}
當我們用一樣的 print_shape(&shape)
承接 Rectangle
類型的物件,並且印出資訊時,我們可以發現這次 shape
成功依照傳入的類型決定呼叫哪一版的 area
以及 perimeter
。因此我們可以得出以下結論,若是我們利用重寫機制重寫函式,並且利用用基類引用(或是指針)指向衍生類時,基類引用對應到的的函式版本會是基類的,但我們利用 virtual 機制 override 時,基類的引用會在 run time 的時候決定執行對應版本的衍生類函式。
純虛函式和抽象基類
常理來說我們不希望任何使用者利用 Shape 這個類型創建任何物件,因為 Shape 單純作為一個通用的形狀,並非某個具體的形狀。為了達成此目的,我們在 virtual function 宣告尾端加上 = 0
,代表此 virtual function 是一個純虛函數 (pure virtual function)。內含純虛函數的類型稱為抽象基類 (abstract base class),抽象基類無法為純虛函數定義,使用者也無法利用抽象基類創建任何物件,只能利用抽象基類的指標指向有明確定義純虛函數的衍生類物件,因此以下程式碼編譯的時候會發生報錯。
// Shape.h
class Shape
{
public:
Shape(): n(-1), name("NaN") {}
Shape(short n, std::string name);
~Shape() = default;
virtual float area() = 0;
virtual float perimeter() = 0;
friend void print_shape(std::ostream &os, Shape &shape);
protected:
short n;
std::string name;
};
// main.cpp
#include "Shape.h"
#include <iostream>
int main()
{
Shape shape;
return 0;
}
/*
error: cannot declare variable 'shape' to be of abstract type 'Shape'
6 | Shape shape;
| ^~~~~
*/
若抽象基類的衍生類沒有定義純虛函數的話,則該衍生類也是一個抽象基類,換句話說純虛函式強迫所有衍生類定義出他們自己的版本,不像虛函式可以繼承覆類的定義。這裡需要註明的是,若抽象基類 A 的子類 B 定義出了一版純虛函式,那 B 就不再是抽象基類,將來孫類 C (子類的子類)繼承了 B,就並非一定要定義出 C 的 virtual function,可以直接從 B 繼承此 virtual function。舉例來說,我們今天定義另一個形狀 Square
,從 Rectangle
繼承而來,因為 Rectangle
已經定義出了純虛函式,此時已經不再是抽象基類了,所以 Square
繼承 Rectangle
時可以沿用 Rectangle
所定義的 area
和 perimeter
,並不一定要定義自己的虛函式。而事實上 Square
的面積和周長計算公式都和 Rectangle
一模一樣,因此繼承後我們只要定義 Square
的建構子即可。
// Shape.h
class Square: public Rectangle
{
public:
Square() = default;
Square(float w, std::string name);
~Square() = default;
friend void print_shape(std::ostream &os, Shape &shape);
};
// Shape.cpp
Square::Square(float w, std::string name): Rectangle(w, w, name) {}
#include "Shape.h"
#include <iostream>
int main()
{
Square square(4, "4x4 square");
print_shape(std::cout, square);
/*
Name: 4x4 square
n: 4
Area: 16
Perimeter: 16
*/
return 0;
}
至此我們已經將虛函式、純虛函式、抽象基類的概念介紹完畢,希望這邊文章對你有所幫助。另外虛函式還有一些其他的規則,可以參考搞懂 virtual function 的使用規則。
Reference
C++ Primer 5th edition, Lippman, Stanley B., Josee Lajoie, Barbara E. Moo
Pingback: 搞懂 Virtual Function 的使用規則