還是搞不懂 virtual function 嗎?來看這篇吧!簡明 C++ virtual function 的機制與概念

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 類型,因此會發生類型轉換。可以注意的是衍生類永遠可以被轉換成基類,因為只要將衍生類型多出來的部分省略掉,留下衍生類和基類共有的部分即可。在這個例子中,ShapeRectalge 共有的成員有:area()perimeter()nname,所以 print_totalshape 實際上只擷取 rect 裡面的那四個成員並加以操作。不幸的是在此例中,我們會發現印出來的資訊會和我們預想的不同,除了 nname 以外,我們會看到 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 版本的,而 Rectanglen 沒有重寫,因此完整繼承 Shapen,所以他們兩個的 n 是相同的,print_shape 就會將 Rectanglen 印出。

因此為了將正確的 Rectangle 資訊印出來,我們只能額外定義一個 print_rectangle(rectangle &r) 函式,用相同的類型承接輸入的物件。如果我們定義了其他形狀的類型,CircleSquareTrianglePentagonHexagon等等,那我們被迫需要定義出一系列對應的 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 所定義的 areaperimeter,並不一定要定義自己的虛函式。而事實上 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

Show 1 Comment

1 Comment

Comments are closed