函數指標 Function Pointer

指標 Pointer 那一章中,我們已經了解到指標是用來儲存一個變數的地址。這章我們要來了解什麼是函數指標。

實際上函數指標也是用來儲存地址,唯一的差別在於它是用來儲存函數的地址,而非變數。

什麼是函數指標

開頭講到,函數指標是用來儲存函數的地址。沒錯,函數跟變數一樣也有地址!

這是因為程式若在執行時遇到函數,它會跳到函數所在的地址,執行在其中的程式碼,之後再跳回一開始跳走的地方。

那麼我們可以如何知道函數的地址呢?我們可以這樣做:

int func()
{
    return 3;
}

int main()
{
    // 第一種方法
    std::cout << "函數的地址:" << reinterpret_cast<void*>(func) << std::endl;

    // 第二種方法
    std::cout << "函數的地址:" << func << std::endl;

    return 0;
}

我們應該會看到類似這樣的輸出

函數的地址:0x401186

這邊可以看到有兩種方法可以得到函數的地址,大部分是第一種。但有些編譯器聰明地知道我們是想要印出函數的地址,所以才有第二種。

為什麼需要函數指標

了解函數指標是什麼之後,另一個重要的問題來了:為什麼我們需要有函數指標?

一個非常常見的函數指標應用是回調函數 Callback Function。回調函數讓一個函數可以在執行時,根據收到的不同函數地址而呼叫相對應的函數。

另一個用途則是可以客製化函數。在某些函數中,可能有些特定邏輯並不是固定的。我們可以針對那些區域自行撰寫新的邏輯。一個非常好的例子就是 C++ 中的 sort() 函數

函數指標還有其他的功能像是 virtual function 這種進階的應用。

我知道現在你可能都聽不太懂這些在幹嘛,沒關係,接下來我都會慢慢介紹的!

函數指標長怎麼樣

了解了函數指標是什麼後,我們終於要來看看函數指標到底長怎樣了!

一個非常非常簡單的函數指標長這樣:

int (*funcPtr)();

funcPtr 就是一個函數指標,這個函數指標指向了一個函數,而這個被指到的函數並沒有任何參數,而且會回傳一個整數 int

因為 funcPtr 是一個一般指標,所以它可以隨時變更所指向的函數,只要函數擁有相同的參數以及回傳型態即可!

我們來看另一個函數指標:

int (*funcPtr)(int, float);

這個函數指標和前一個有相同的概念,唯一的差別在於這個被指到的函數擁有兩個參數,一個是整數 int,另一個則是浮點數 float

實際應用

接下來我們來看看函數指標的實際應用,我們會分為這幾點討論:

  • 賦予函數指標實際函數
  • 利用函數指標呼叫函數
  • 回調函數 Callback function

賦予函數指標實際函數

前一節我們知道了函數指標長怎樣,但我們要怎麼賦予它一個實際的函數呢?

我們可以這樣做:

int func1()
int func2()

int main()
{
    // funcPtr 指向 func1
    int (*funcPtr)(){ &func1 };

    // funcPtr 指向 func2
    fcnPtr = &func2;

    return 0;
}

可以看到我們有兩種方式,第一種是在創建 funcPtr 時就賦予一個函數,第二種是在之後利用 & 的取址符號取得函數地址並進行賦予。

我們來看看一些可能會犯的錯誤:

int func1();
int func2(int);

int main()
{
    // 回傳型態不同
    float (*funcPtr)(){ &func1 };

    // 參數不相同
    int (*funcPtr)(){ &func2 };

    return 0;
}

參數和回傳型態的不同都會造成程式報錯。

利用函數指標呼叫函數

被賦予了實際函數後,我們要如何呼叫該函數呢?

int func(int x)
{
    return x;
}

int main()
{
    int (*funcPtr)(int){ &func };
    (*funcPtr)(5);

    return 0;
}

和普通的指標擁有一樣概念,我們要使用 * 進行取值,之後再將參數傳進就好啦!其實和一般的函數呼叫沒有差太多。

不過現今的編譯器已經越來越聰明,即使我們不用 *,它大概率也能猜出來我們是要呼叫函數。

因此下面這樣對現今大多數的電腦來說也行得通:

int main()
{
    int (*funcPtr)(int){ &func };
    funcPtr(5);

    return 0;
}

回調函數 Callback function

函數指標一個非常普遍被運用的方式是將它當作另一個函數的參數。這個被當作參數的函數,叫做回調函數。

回調函數的功能在於提供使用者一個可以自己定義的區塊。一個很經典的例子就是 std::sort()

std::sort() 這個函數看名字應該就可以知道,它是目的是為了排序一串數字,預設是由小到大進行排序。

但是我們可以透過將客製化函數當作參數傳進 std::sort(),達到更改排序的邏輯。

我們直接來看一個例子:

bool compare(int n1, int n2)
{
    return n1 > n2;
}

int main() {
    bool (*comp)(int, int){ &compare };
    std::vector vec{1, 3, 5, 2, 4};
    std::sort(vec.begin(), vec.end());
    
    std::cout << "default: ";
    for (auto i : vec)
    {
        std::cout << i << " ";
    }
    std::cout << std::endl;
    
    std::cout << "callback function: ";
    std::sort(vec.begin(), vec.end(), comp);
    for (auto i : vec)
    {
        std::cout << i << " ";
    }

    return 0;
}

這樣的輸出會是:

default: 1 2 3 4 5 
callback function: 5 4 3 2 1

我們可以看到,comp 被我們當作函數指標傳進 std::sort(),進而更改了內部排序的邏輯。

為什麼我知道 comp 需要兩個參數並且回傳型態是 bool 呢?我們可以從 官方文件 中的「參數 Parameters」看出這點。

美化

我相信大家都覺得函數指標長得有夠醜的,一堆括弧跟星星,用起來超級不方便的。

其實我自己也這麼覺得,有時候也會不小心記錯符號。

但我們有幾個方法可以美化這個東西,讓我們用起來更方便!

  • 使用 type alias
  • 使用 std::function()

使用 type alias

type alias 讓我們可以將複雜的型態轉變為平易近人的名稱。

比如說:

using FuncType = int(*)(int);

// 超難用
bool func(int x, int (*funcPtr)(int));

// 平易近人
bool func(int x, FuncType funcPtr);

這樣是不是就變得很平易近人啦!我們只需要打一次就可以了,剩下的使用自定義的名稱就好。

使用 std::function()

其實 C++ 也知道函數指標長得很醜,所以他們有自己建立這個存在於標准函式庫的 std::function() 給我們使用。

在使用前,我們必須引入 functional 這個函式庫。接著我們就可以這樣用:

bool func(int x, std::function<int()> funcPtr);

我自己個人還是比較喜歡使用 type alias 的做法啦,但有些人覺得使用 std::function() 對於理解上來說更直覺。

但如果說這個指標只會出現一次的話,那就用 std::function() 吧!如果會出現多次的話再考慮使用 type alias。

總結

這章我們了解了一個進階的概念,函數指標,包括它為什麼存在、它長怎麼樣、它的應用等等。

總結來說,函數指標在如果需要將函數儲存在陣列之中,或是如果需要將一個函數當作變數傳進另一個函數之中時,會變得非常實用。

那有關函數的篇章就到這裡啦!下一章我們會進入到作用域的世界~