今天在Qt中使用unix函數庫時,需要傳遞一個類的成員函數的指針,遂報錯。。。google之,淺嘗成員函數指針一詞。
原文出處https://kelvinh.github.io/blog/2014/03/27/cpp-tutorial-pointer-to-member-function/,原文内容有一些錯誤,已修正。
關于成員函數指針
成員函數指針是C++最少用到的文法之一,甚至有經驗的C++碼農有時候也會被它搞暈。這是一篇針對于初學者的教程,同時也給有經驗的碼農分享了一些我個人對底層機制的挖掘。在開始之前,讓我們先看一段代碼:
//mem_fun1.cpp
#include <iostream>
class Foo {
public:
Foo(int i = 0) { _i = i; }
void f() {
std::cout << "Foo::f()" << std::endl;
}
private:
int _i;
};
int main() {
Foo *p = 0;
p->f();
}
// Output:
// Foo::f()
為什麼我們能通過一個空指針調用成員函數?看起來好像編譯器根本不鳥p的值是什麼,隻介意p的類型。别着急,咱先吊吊胃口,把答案留到後面的章節。現在,我們所能知道的是,編譯器準确地知道要調用哪個函數。這就是所謂的“靜态綁定”。因為成員函數可以靜态綁定(并不是總能靜态綁定,待會兒讨論),是以它們的位址是在編譯階段決定的(同樣并不是永遠如此)。直覺地講,應該有一種方法可以儲存成員函數的位址。而且,真有這麼一種方法,那就是——成員函數指針。
C++文法
下面的文法展示了如何聲明一個成員函數指針:
Return_Type (Class_Name::* pointer_name) (Argument_List);
Return_Type: member function return type.
Class_name: name of the class in which the member function is declared.
Argument_List: member function argument list.
pointer_name: a name we'd like to call the pointer variable.
例如,我們定義一個類
Foo
和一個成員函數
f
:
int Foo::f(string);
我們可以給這個成員函數指針起一個“高大上”的名字
fptr
,是以我們就有了下面的内容:
Return_Type: int
Class_Name: Foo
Argument_List: string
declaration of a pointer-to-member function named "fptr":
int (Foo::*fptr) (string);
現在,指定一個成員函數給我們“高大上”的
fptr
:
fptr = &Foo::f;
當然,就連腦殘都知道可以将聲明和初始化結合起來:
int (Foo::*fptr) (string) = &Foo::f;
為了通過函數指針來調用成員函數,我們使用成員指針選擇操作符(翻譯君表示也不知道該怎麼翻譯,原文是pointer-to-member selection operators),
.*
或者
->*
。下面的代碼示範了基本用法:
#include <iostream>
#include <string>
using namespace std;
class Foo {
public:
int f(string str) {
cout << "Foo::f()" << endl;
return 1;
}
};
int main(int argc, char *argv[]) {
int (Foo::*fptr) (string) = &Foo::f;
Foo obj;
(obj.*fptr)("str"); // 通過對象來調用 Foo::f()
Foo *p = &obj;
(p->*fptr)("str"); // 通過指針來調用 Foo::f()
}
注意:
.*fptr
綁定fptr到對象obj,而
->*fptr
則綁定fptr到指針p所指向的對象。(還有一個 重要的差別 是:我們可以重載後者,卻不能重載前者)。在
(obj.*fptr)
和
(p->*fptr)
兩邊的括号是文法所強制要求的。
成員函數指針不是正常指針
成員函數指針不像正常指針那樣儲存某個“準确”的位址。我們可以把它想像成儲存的是成員函數在類布局中的“相對”位址。讓我們來展示一下二者的不同。我們隻對類
Foo
做一個小手術:将成員函數
f
變成
static
:
#include <iostream>
#include <string>
using namespace std;
class Foo {
public:
static int f(string str) {
cout << "Foo::f()" << endl;
return 1;
}
};
int main(int argc, char *argv[]) {
// int (Foo::*fptr) (string) = &Foo::f; // 錯誤
int (*fptr) (string) = &Foo::f; // 正确
(*fptr)("str"); // 調用 Foo::f()
}
一個靜态成員函數沒有
this
指針。除了它和其它的類成員共享命名空間Foo(在我們的例子中命名空間是
Foo::
)之外,它和正常全局函數是一樣的。是以,靜态成員函數不是類的一部分,成員函數指針的文法對正常函數指針并不成立,例如上面例子中的靜态成員函數指針。
int (Foo::*fptr) (string) = &Foo::f;
上面這行代碼在g++ 4.2.4中編譯的錯誤資訊為:“不能将
int (*)(std::string)
轉化成
int (Foo::*)(std::string)
”。這個例子證明了成員函數指針不是正常指針。另外,為什麼C++如此費心地去發明這樣的文法?很簡單,因為它和正常指針是不同的東西,而且這樣的類型轉換也是違反直覺的。
C++類型轉換規則
非虛函數情形
我們在前面一節看到,成員函數指針并不是正常指針,是以,成員函數指針(非靜态)不能被轉換成正常指針(當然,如果哪個腦殘真想這麼做的話,可以使用彙編技術來暴力解決),因為成員函數指針代表了 偏移量 而不是 絕對位址 。但是,如果是成員函數指針之間互相轉換呢?
//memfunc4.cpp
#include <iostream>
class Foo {
public:
int f(char *c = 0) {
std::cout << "Foo::f()" << std::endl;
return 1;
}
};
class Bar {
public:
void b(int i = 0) {
std::cout << "Bar::b()" << std::endl;
}
};
class FooDerived : public Foo {
public:
int f(char *c = 0) {
std::cout << "FooDerived::f()" << std::endl;
return 1;
}
};
int main(int argc, char *argv[]) {
typedef int (Foo::*FPTR) (char*);
typedef void (Bar::*BPTR) (int);
typedef int (FooDerived::*FDPTR) (char*);
FPTR fptr = &Foo::f;
BPTR bptr = &Bar::b;
FDPTR fdptr = &FooDerived::f;
// bptr = static_cast<void(Bar::*)(int)>(fptr); // 錯誤
fptr = static_cast<int(Foo::*)(char*)>(fdptr); // 正确,向上轉型
Bar obj;
( obj.*(BPTR) fptr )(1); // 調用 Foo::f()
}
// Output:
// Foo::f()
在上面的代碼中,首先使用
typedef
。它讓這些繁瑣的定義變得清晰起來。fptr的類型是:
int (Foo::*) (char*);
或者等價地說——FPTR。如果我們仔細看上面的代碼:
bptr = static_cast<void(Bar::*)(int)>(fptr);
這一行會出錯,因為 不同的非靜态非虛成員函數具有強類型是以不能互相轉化 ,但是:
fptr = static_cast<int(Foo::*)(char*)>(fdptr);
這一行卻是正确的!我們可以将一個指向派生類的指針指派給一個指向其基類的指針(即"is-a"關系),這個規則提供了将
FooDerived::*
應用到任何
Foo::*
能被應用的地方的基本保證。在代碼最後兩行:
Bar obj;
( obj.*(BPTR) fptr)(1);
盡管我們想要調用的是
Bar::b()
,但是
Foo::f()
卻被調用了,因為fptr是靜态綁定(翻譯君注:這裡的靜态綁定,即指在編譯階段,fptr的值已經确定了,是以即使進行強制轉換,依然調用的是Foo類的f()函數)。(請圍觀成員函數調用和
this
指針)
虛函數情形
我們隻将前例中的所有成員函數變成虛函數,其它都不動:
#include <iostream>
class Foo {
public:
virtual int f(char *c = 0) {
std::cout << "Foo::f()" << std::endl;
return 1;
}
};
class Bar {
public:
virtual void b(int i = 0) {
std::cout << "Bar::b()" << std::endl;
}
};
class FooDerived : public Foo {
public:
int f(char *c = 0) {
std::cout << "FooDerived::f()" << std::endl;
return 1;
}
};
int main(int argc, char *argv[]) {
typedef int (Foo::*FPTR) (char*);
typedef void (Bar::*BPTR) (int);
FPTR fptr = &Foo::f;
BPTR bptr = &Bar::b;
FooDerived objDer;
(objDer.*fptr)(0); // 調用 FooDerived::f(),而不是 Foo::f()
Bar obj;
( obj.*(BPTR) fptr )(1);// 調用 Bar::b(),而不是 Foo::f()
}
// Output:
// FooDerived::f()
// Bar::b()
如我們所看到的,當成員函數是虛函數的時候,成員函數能夠具有多态性并且現在調用的是
FooDerived::f()
,而且
Bar::b()
也能被正确調用了。因為 “一個指向虛成員的指針能在不同位址空間之間傳遞,隻要二者使用的對象布局一樣” (C++ Bjarne Stroustrup 的 《C++程式設計語言》 )。當成員函數是虛函數的時候,編譯器會生成虛函數表,來儲存虛函數的位址。隻要成員函數在類中的相對位址一樣,即使是在不同類之間,成員函數指針也能互相轉換。這是和非虛函數之間的最大不同,是以,運作時的行為也是不同的。
成員函數指針數組及其應用
成員函數指針的一個重要應用就是根據輸入來生成響應事件,下面的
Printer
類和指針數組
pfm
展示了這一點:
#include <stdio.h>
#include <string>
#include <iostream>
class Printer { // 一台虛拟的列印機
public:
void Copy(char *buff, const char *source) { // 複制檔案
strcpy(buff, source);
}
void Append(char *buff, const char *source) { // 追加檔案
strcat(buff, source);
}
};
enum OPTIONS { COPY, APPEND }; // 菜單中兩個可供選擇的指令
typedef void(Printer::*PTR) (char*, const char*); // 成員函數指針
void working(OPTIONS option, Printer *machine,
char *buff, const char *infostr) {
PTR pmf[2] = { &Printer::Copy, &Printer::Append }; // 指針數組
switch (option) {
case COPY:
(machine->*pmf[COPY])(buff, infostr);
break;
case APPEND:
(machine->*pmf[APPEND])(buff, infostr);
break;
}
}
int main() {
OPTIONS option;
Printer machine;
char buff[40];
working(COPY, &machine, buff, "Strings ");
working(APPEND, &machine, buff, "are concatenated!");
std::cout << buff << std::endl;
}
// Output:
// Strings are concatenated!
在上述代碼中,
working
是一個用來執行列印工作的函數,它需要幾個參數:1. 菜單選項;2. 可用的列印機;3. 字元串目的地;4. 字元串來源。上述代碼中字元串來源是兩個字元串常量"Strings "和"concatenated!",而成員函數指針數組被用來根據菜單選項執行相應的列印動作。
成員函數指針另外一個重要的應用可以在STL的
mem_fun()
中找到。(翻譯君去看了一下
mem_fun()
的源代碼,原來是用成員函數來構造仿函數functor的。)
成員函數調用和 this
指針
this
現在我們回到文章最開始的地方。為什麼一個空指針也能調用成員函數?對于一個非虛函數調用,例如:
p->f()
,編譯器會生成類似如下代碼:
Foo *const this = p;
void Foo::f(Foo *const this) {
std::cout << "Foo::f()" << std::endl;
}
是以,不管p的值是神馬,函數
Foo::f
都可以被調用,就像一個全局函數一樣!p被作為
this
指針并當作參數傳遞給了函數。而在我們的例子中
this
指針并沒有被解引用,是以,編譯器放了我們一馬(翻譯君表示,這其實跟編譯器沒有關系,即使我們在成員函數中使用this指針,編譯照樣能通過,隻不過在運作時會crash)。假如我們想知道成員變量
_i
的值呢?那麼編譯器就需要解引用
this
指針,這隻有一個結果,那就是我們的好兄弟——未定義行為(undefined behavior)。對于一個虛函數調用,我們需要虛函數表來查找正确的函數,然後,
this
指針被傳遞給這個函數。
這就是非虛函數、虛函數、靜态函數的成員函數指針使用不用實作方式的根本原因。
結論
簡單總結一下,通過上述文章,我們學到了:
- 成員函數指針聲明和定義的文法
- 使用成員指針選擇操作符來調用成員函數的文法
- 使用
寫出更加清晰的代碼typedef
- 非虛成員函數、虛函數、靜态成員函數之間的差別
- 成員函數指針和正常指針的對比
- 不同情形下的成員函數指針轉換規則
- 如何使用成員函數指針數組來解決特定的設計問題
- 編譯器是如何解釋成員函數調用的
我衷心希望這篇教程能打開通往上述要點的相關進階技巧的大門,例如多重繼承、虛繼承下的成員函數指針,以及編譯器的相關實作。。