C++ 的顯性轉型與隱性轉型 - Explicitly/ImplicitlyType Conversion

從以前看到 C++ code 的時候,每次都會看到 static_cast 或是 dynamic_cast 的出現,覺得他們兩個的語意似乎是成對的,但是又不真正清楚它們的 dynamic 是在 dynamic 什麼意思的,static 又是在幹什麼的。心中在想的轉型,其實還是停留在 C 語言中那個一對括號就能解決的轉型,想說應該是在算整數除法時,或是一些指標操作時會出現的東西吧。

有時還是會看到 reinterpret_cast 就覺得更困惑了. 在開始看 Essential COM 這本書時,在講到 QueryInterface 實作時,就開始出現大量的 static_castreinterpret_cast。在第一章簡介時還提到 dynamic_cast 在二進位上沒有可攜性, 如此 blah blah 的,於是我就下定決心要來 survey 一下,有關 c++ 在推出這 4 種 named casting,他們的用處、用法、跟要注意的地方。

下面所打的文章,是我在看完多方網路資源(ppreference.com、Stack Overflow、跟 Wiki) 和書藉( C++ Primer、 Effective C++、跟 The C++ Programming Language)教學,所整理出來的心得筆記,如有錯誤請在留言指正啦。

顯性轉型 (Explicitly Casting)

顯性轉型也就是明白地,確實地告訴編譯器,我知道自己在做什麼,這一行轉型的安全會由我自己負責,你不要私自幫我決定轉型的決策。那麼另外也會有隱性轉型(Implicitly Casting),就是在編譯時期,編譯器會背著你做一個語法上安全的自動轉型,有時候你不希望由編譯器來幫你做轉型的動作,或是要為了解決某些困難的問題,想要卸除編譯器的安全限制,就可以使用顯性轉型來達到自己的目的。

Named Casting - 改良自 C 的轉型

C 語言中的 casting ,在 C++ 中使用時被稱作為 c style casting,是利用一對小括號運算子,來做到 強制型別轉換。由於型別轉換有很多種,有單純的算術型別轉型,也有危險的大絕轉型;有安全的 upcast,也有危險的去 const 轉型。

不同類型的轉型,應該要被小心地分別處理,但是在 c style casting 的裡面,所有不同類型的轉型,通通都被包在裡面,隱藏住了細節。於是在 C++ 就定義了代表各種意思的四種轉型,稱之為 named casting。來讓程式員在填寫或是在閱讀程式碼的時候,能夠明白每一行程式碼在轉型時,所使用到的類型,來減少程式的 bug 與 debug 的時間。

named casting 一共有四種,其中一種是專門用在/也只能用在 含有 virtual function 的 class 的 dynamic_cast,另外三種則是從原本 c style casting 分離出來的三種 casting ,分別是 const_cast, static_cast, 跟 reinterpret_cast

1. Const Casting

const_cast< type-id >( expression )

  • 擁有修飾子的型別 轉成 無修飾子的型別 的操作
  • 其中修飾子包括 (const, violate, 跟 mutable)

任何替物件加上修飾子的動作,都是屬於 隱性轉型,編譯器會默默幫你轉換,是可接受且不會出錯的。但是你當要反向轉型時,就必須明確使用 const_cast 運算子來告訴編譯器。

由於 const 修飾子可以預防你在某些情況下,不會寫入到 readonly 的記憶體區塊上,而防止執行期的記憶體存取錯誤,是一個非常重要的防呆功能,因此移除掉 const 是非常危險的操作。

2. Reinterpret Casting

reinterpret_cast < type-id >( expression )
英漢翻譯: reinterpret (vt.) 重新解釋 [http://cdict.net/?q=reinterpret]

  • 作用於指標,保持指標內部原記憶體的 layout,可在兩個沒有關係的型別間作指標轉型操作。
  • 因為是作用於指標,所以也可以將某個 int 的值傳換到某個指標型別。

reinterpret_cast 是一個 bit 層級的轉型,所以要你在操作變數的 bits 時,或是情況在掌握當中時,才可以使用 reinterpret,基本上,他就是所謂的轉型界的放大絕。轉型時,指標所指的內容原封不動,就強迫強硬地告訴編譯器我就是要這個,買給我!任何的型別不合,所造成的危險,都會由程式員自己負責。就是因為它非常危險,就更應該明白地使用 reinterpret_cast 宣告說,「在這行 code 將要使用一個,可能會造成危險的大絕轉型。」

  • 由於它是屬於底層 bits layer 的操作,所以它是對底層機器有相依性,所以不保證在每一個編譯器上有同樣的結果,就是所謂的不 portable。
  • 但它也是唯一保證,轉過去目標型別再轉回來原來型別,物件的 Bits Layout 不會被改變的轉型。

3. Static Casting

static_cast < type-id > ( expression )

在程式編譯時期裡頭,會發生很多隱性轉型,來幫程式員做到有兩個有相關性型別之間的轉換。什麼叫做有相關性的轉換呢,這裡分成四個類型:算術轉型void 指標轉型class 型別轉型、跟繼承關係轉型。在這四種有轉型中,有些是單向性,所以在你要反向轉換時,就會需要用到 static_cast,來明確告知編譯器來做反向轉型。 (除了 class 型別轉換無法單向轉雙向)。

3.1 算術轉型

boolshortcharintlonglong longfloatdoublelong double 跟它們的 unsigned 版本。這些型別彼此之間可以做雙向轉換。只是在做運算的時候,會依一個比較規則,來去做隱性轉型。像是在只有 bool, short, char, 跟 int 時,會全部轉成 int;整數在遇到浮點數時,會自動轉成浮點數,基本上的規則就是盡量留住較多資訊。所以當你有一段較為複雜的運算式時,你不想要讓編譯器來幫你自動轉型,像是整數除法會被 round 的問題,就可以使用 static_cast 來明確告訴編譯器,自己想要轉換的型別。

//由編譯器幫你出錯
int currentSeconds = 12;
int endSeconds = 50;
int scrollBarMaxPosition = 100;
int scrollBarCurrentPosition = scrollBarMaxPosition * (currentSeconds / endSeconds); // 12/50 = 0
assert(24 == scrollBarCurrentPosition); /* GG here */
//預先轉行成 double,來防止被 round 成 0
int scrollBarCurrentPosition = scrollBarMaxPosition * ( static_cast<double>(currentSeconds) / endSeconds);
assert(24 == scrollBarCurrentPosition); /* OK */

不過我覺得在算術轉型上,是唯一可以用 c style casting 來取代的地方,因為在一條算式間插入一堆 static_cast<int>()static_cast<double>()的,似乎會喪失可讀性。此外在這裡的轉型,因為都是在算術範籌裡的轉換,相比之下比較安全,看 Code 人也不會不知道你的目的。

// 用 (double) 來取代 static_cast<double>() 做單純的算術運算
int currentSeconds = 12;
int endSeconds = 50;
int scrollBarMaxPosition = 100;
int scrollBarCurrentPosition = scrollBarMaxPosition * ((double)currentSeconds / endSeconds);
// much better
assert(24 == scrollBarCurrentPosition); /* OK */

若你的編譯器 -Wconversion flag 有打開的話,而且你知道你正在轉的 double to int 會有小數點被 trunk 掉,又想叫編譯器安靜,也可以使用 static_cast來叫他閉嘴,因為你知道自己在做什麼。

// 跳 warning 很煩
double pi = 3.14;
int three = pi;  // warning: implicit conversion loses integer precision: 'double' to 'int'
// 好多了
double pi = 3.14;
int three = static_cast<int>(pi);  // OK

3.2 void 指標轉型

任何的指標型別像 int*, char*, std::string*, 和其他,都可以接受轉型成 void 指標,但是 void 指標無法被直接轉型回去,除非使用 static_cast 來明確告知。

// cat.h 
class Cat{void mew(){};};

...
// main.cpp
// casting to Cat*->void*
Cat cat = new Cat();
void *vpCat = cat; // ok
// casting back - void*->Cat*
Cat *cpCat = 0;
cpCat = vpCat; //  error: assigning to 'Cat *' from incompatible type 'void *'
...
// 明確告知型別
cpCat = static_cast<Cat*>(vpCat); // OK

可能的問題

不過由於是 void 指標 轉換回到 某一型別指標,編譯器也無法知道你的指標型別,是不是符合、是不是安全地可以轉型回去,所以最好只有在受控制的情況下使用,才會比較安全。

  
// main.cpp
Dog dog;
DoMeow(&dog);     // 傳一個 Dog* 進入 void*, 編譯器不會叫

// cat.h
void DoMeow(void *vp){ // 傳入的 Dog* 被隱性轉成 void*
  Cat* cat = static_cast<Cat*>(vp); // void* 的 (Dog 物件) 被轉成 Cat*
  cat->meow();          // GG here, Dog cannot meow
}

3.3 class 型別轉型

使用建構子

當你有一個 Class A,有一個只有一個參數的建構子,其參數的型別為 class T ,那麼當 T 物件 遇到要轉型成 class A 的情況時:

// meow.h
class Cat{};
class Lion{Lion(Cat& cat){};};

// main.cpp
Cat cat;
Lion lion = static_cast<Lion>(cat); // ok
Lion lion2 = cat; // ok

在遇到 class Lion 的 assign operator 時,cat 就會偷偷被 Lion 的建構子接收,而達到 C++ 神奇的 隱性轉換 。在 C++ 裡面,用到這種隱性建構子轉換的例子其中有一個就是 std::string。在很多 function 它們只吃 std::string 型別當參數時,你卻傳入了 const char* 的字串當參數,程式也會正常運行的原因,就是因為 std::string 幫你偷偷生成了一個以 const char* 為參數的建構子初使化的 std::string 物件。

如果有時你不想發生這種自動過頭的事,可以使用 explict 放在建構子的前面宣告,來阻止編譯器來幫你做這些暗通款曲的事。

使用 Conversion function

剛剛講的是「讓 目標 class 自己處理不同的 來源 class 轉型」,那麼現在這個的就是「 來源 class 自己處理要如何轉型成 目標 class 型別」。只要在你的 class 中,定義一個 operator T(),並且回傳一個 T 型別的值,就可以達到對 T 的轉型。

// meow.h
class Cat{operator Lion(){ return Lion()};};
class Lion{};

// main.cpp
...
Cat cat;
Lion lion = cat; // ok
Lion lion2 = static_cast<Lion>(cat); // ok
...

3.4 繼承關係轉型

class Base{public: int getAge();}; 
class Derived : public Base
{public: const char* getName();}; 

大家都知道 多型 就是利用一個 Base Class 的指標指向 Derived Class在 heap 的物件時,在執行期時可以 resolve 在 class Derived 內部的 virtual function 位址,來去正確地呼叫 Derived class 的 overrited function,物件導向有一部份的基礎就是依靠這個強大的多型,來去做非常有彈性的設計。

upcast

那麼在 Base *base = new Derived() 這行理所當然的程式碼裡,之中發生的事其實很奇怪,明明 new Derived() 產生的是一個 Derived* 的物件,為什麼可以 assign 到 Base* 型別的 base 上呢?因為在這裡,編譯器幫你做了一個隱性轉型,又叫做 upcast ,來讓 new Derived() 可以被轉成一個 Base* 型別。

在 upcast 中,因為你是用 Base* 的指標,不管你 new 一個 Derived (有 2 個 functions),還是一個 Base(只有 1 個 function),它的函式操作範圍永遠只有 int getAge() 的位址區,不會有存取到神秘的記憶體的情況發生。所以編譯器認為是合法的,也樂意幫你作 upcast 的轉換。

downcast

將一個 Base* 型別的指標,轉換到 Derived*型別。這個時候就會有危險的情況發生。如果你只配置一個 Base 的空間,因為裡面只有一個 function ,所以只會有一個 int 的空間 來存放 int getAge() 的位置,有效的操作為 this 代表本 object , this+4 代表 int getAge()。當你強迫轉型成 Derived*,那麼 base->getName() 就會做出存取 this+8 位址,這種記憶體位置撈過界的情況發生。

Base *pBase = new Base();
Derived *pDerived = static_cast<Derived*>(pBase);
pDerived->getName(); // Undefined Behavior

在某些程式設計的情況中,還是會碰到需要 downcast ,像是:

class CWnd{
public:
  virtual void SetCaption(const char*);
};
class CTextCWnd: public CWnd{ 
public:
  virtual void SetCaption(const char*);
  virtual void SetFont(const char*);
};
class CScrollBar: public CWnd
{  
 virtual void SetCaption(const char*);
 virtual void SetPosition(int position);
};

void handle(CWnd* cWnd)
{
    if(isCScrollBar(cWnd)) // use RTTI
    {
        CScrollBar *pScrollBar = static_cast<CScrollBar*>(cWnd);
        pScrollBar->SetPosition(50);
    }
}

不過基本上可以利用 visitor pattern解決這個問題。另外根據在 《Essential COM》書中描述,微軟的 COM Model 裡頭的 IUnknown::QueryInterface(RIID iid, void** dptr); 就有大量用到 downcast 來做到切換不同 interface 的目的。

4. Dynamic Casting

dynamic_cast < type-id > ( expression )

  • 只能作用於含有至少一個 virtual function 的 class
  • 若型別不符,會回傳 0 表示,來讓程式員判斷例外的程序
  • 會動用到 RTTI 而產生一筆查尋的 overhead,來當做是型別安全的 tradeoff。

static_cast 談到 donwcast 問題,因為 static_cast 無法保證安全 downcast,也是因為編譯器無法在編譯時期,就得知在 heap 的物件型別,它只能在執行期時知道,因為物件是執行期時才在 heap 產生,所以 dynamic_cast 會使用 RTTI (RunTime Type Information) 來去得知物件的型別。若是型別不符合,就會回傳 0 代表無法成功 downcast,進而讓程式員處理不符合的情況。

void handle(CWnd* cWnd)
{
    CScrollBar *pScrollBar = dynamic_cast<CScrollBar*>(cWnd) == 0) // use RTTI
    if(pScrollBar == 0)
    {
        ...
        std::cerr << "E_DOWNCAST_FAIL" << std::endl;
        ...
    }else{         
        pScrollBar->SetPosition(50);  // typesafe ensured
    }
}
使用上的時機

由於執行期查訊動作的 overhead 會消耗一些速度來保證型別安全,所以只要在 非底層 class 或是 非速度很要求的 class 中,都應該使用 dynamic_cast 來取代 static_cast,來防止上述所說的 downcast 問題。

dynamic_cast 的需求

因為 dynamic_cast 所需要的資訊 RTTI ,它是放在 vtable (依照不同編譯器有不同放置位置。)也就是放 virtual function 位址的地方。若你的 class 以及 base class 中沒有任何一個 virtual function,編譯器就不會幫這個 class 的物件配置一個 vtable,那麼在這個 class 的物件上施以 dynamic_cast 就根本運作不起來,編譯器當然也會噴出錯誤。一般來說要解決這種問題,可以宣告你的解構子為 virtual ,就可以對他做 dynamic_cast 了。

5. C style Casting

舊型的 C 轉型,在 C++ 中,會去依照下列順序來一一做轉型的嘗試:
來源: http://stackoverflow.com/questions/332030/when-should-static-cast-dynamic-cast-and-reinterpret-cast-be-used

const_cast
static_cast
static_cast, then const_cast
reinterpret_cast
reinterpret_cast, then const_cast

可以看到 c style casting 會去動用到危險的 const_cast,也會動用到恐怖的 reinterpret_cast,全部一股腦的暴力轉型。所以應該要好好使用 named casting,把很 critical 地方,用各種用途寫得很明白的轉型運算子清楚標明,這樣不管是寫的人知道自己在做什麼,也讓看的人知道自己在做什麼。

參考網站:
http://msdn.microsoft.com/en-us/library/x9wzb5es.aspx
http://stackoverflow.com/questions/332030/when-should-static-cast-dynamic-cast-and-reinterpret-cast-be-used
http://stackoverflow.com/questions/5381690/options-for-class-design-using-safe-downcasting
http://stackoverflow.com/questions/6322949/downcasting-using-the-static-cast-in-c
http://en.cppreference.com/w/cpp/language/implicit_cast
https://anteru.net/2007/12/18/200/

參考書籍:
The C++ Programming Language http://www.books.com.tw/products/F012989629
Effective C++ 3/e中文版 http://www.books.com.tw/products/0010392869
C++ Primer 4/e中文版 http://www.books.com.tw/products/0010334375

comments powered by Disqus