1. 程式人生 > >當C++多繼承遇上型別轉換

當C++多繼承遇上型別轉換

1 由來

客戶用陳舊的VC++6.0進行專案開發,有一塊功能需要我來實現。讓一個早就習慣了VS2013的人去使用C++支援不太好的VC6去做開發實在是非常不爽,於是另闢蹊徑,打算使用VC++2013開發編譯出DLL,供VC6下呼叫即可。使用C++開發DLL的基本原則是減少暴露和介面簡單化,最常用的方式就是使用純虛類匯出介面。另一種就是使用C++實現,但是匯出時只匯出C函式。處於使用的便利性考慮,採用了第一種方式。

2 原型與問題

基本的設計思路可以用如下程式碼描述。
#include <iostream>
#include <hash_map>
using namespace std;


class I1
{
public:
	virtual void vf1()
	{
		cout << "I'm I1:vf1()" << endl;
	}
};


class I2
{
public:
	virtual void vf2()
	{
		cout << "I'm I2:vf2()" << endl;
	}
};


class C : public I1, public I2
{
private:
	hash_map<string, string> m_cache;
};


I1* CreateC()
{
	return new C();
}


int main(int argc, char** argv)
{
	I1* pI1 = CreateC();
	pI1->vf1();


	I2* pI2 = (I2*)pI1;
	pI2->vf2();


	delete pI1;
	return 0;
}


採用基於介面的設計方法,對外只暴露介面類I1和I2,對於實際的實現類C則對外隱藏。客戶在使用的時候,只需要呼叫CreateC()就可以產生C型別的物件,而不必知道C的實現細節。然後通過不同的介面呼叫不同方面的功能。看起來一切還可以,但實際執行卻是有問題的,上述程式碼執行結果如下:

第二行的輸出對應pI2->vf2(),顯然結果是錯誤的,呼叫者的本意是呼叫I2::vf2(),實際卻呼叫了I1::vf1()。隨後我發現這個問題其實在論壇上也有人提出過,也有不少人給出了答案,但是感覺解釋的不夠明確和詳細,所以決定親自一探究竟。

3 分析

對於多繼承下的記憶體佈局問題,請參考本人的博文:http://blog.csdn.net/smstong/article/details/6604388。其實這裡的問題也是與記憶體不就息息相關,也算是對前面這篇博文的一點補充。前面博文指出了使用同一物件呼叫不同的函式時,在被呼叫函式內部的this指標是不同的,以及為什麼不同然而沒有說明這裡的this是何時被確定的,是編譯時?還是執行時?
還是先來看看前面程式碼的記憶體佈局。
之所以會出現pI1和pI2指向了同一個地址,是因為C++編譯器沒有足夠的知識來把IA*型別轉換為IB*型別,只能按照傳統的C指標強制轉換處理,也就是指標位置不變。為了驗證上面的結論,簡單的把pIA和pIB打印出來即可。把main()函式修改為如下:
int main(int argc, char** argv)
{
	I1* pI1 = CreateC();
	pI1->vf1();

	I2* pI2 = (I2*)pI1;
	pI2->vf2();

	cout << "pI1指向的地址為:"<<std::hex << pI1 << endl;
	cout << "pI2指向的地址為:"<<std::hex << pI2 << endl;
	delete pI1;
	return 0;
}

執行結果為:
可見pI1和pI2確實指向了同一個地址,而這個地址就是I1類的虛表。由於虛擬函式是按照順序定位的,編譯器編譯pI2->vf2()的時候,不管實際的pI2指向哪裡,都把它當做指向了I2的虛表,根據I2類定義,推出I2::vf2()這個函式位於其虛表的第0個位置,所以就直接把pI2指向的地址作為vf2來呼叫。而實際上,這個位置恰恰是I1虛表的第0個位置,也就是I1::vf1的位置,所以實際執行時呼叫的是I1::vf1()。其實這種情況是有些特殊的,也就是這個位置正好也是一個函式地址,而且函式原型也一樣,要是有任何不同的地方,就會造成呼叫失敗,反而更容易及時的提醒開發者。如下程式碼所示:
#include <iostream>
#include <hash_map>
using namespace std;


class I1
{
public:
	virtual void vf1()
	{
		cout << "I'm I1:vf1()" << endl;
	}
};


class I2
{
public:
	virtual void vf2()
	{
		cout << "I'm I2:vf2()" << endl;
	}
	virtual void vf3()
	{
		cout << "I'm I2:vf3()" << endl;
	}
};


class C : public I1, public I2
{
private:
	hash_map<string, string> m_cache;
};


I1* CreateC()
{
	return new C();
}


int main(int argc, char** argv)
{
	I1* pI1 = CreateC();
	pI1->vf1();


	I2* pI2 = (I2*)pI1;
	pI2->vf2();
	pI2->vf3();


	cout << "pI1指向的地址為:"<<std::hex << pI1 << endl;
	cout << "pI2指向的地址為:"<<std::hex << pI2 << endl;
	delete pI1;
	return 0;
}


此時的記憶體佈局為:
在執行pI2->vf2()時,執行的是I1::vf1(),但是不會報錯。當執行pI2->vf3();時,由於呼叫的地址並不是一個函式指標,所以會報錯。

4 解決思路

上述問題的發生,根本原因就是介面指標指向了錯誤的地方,而導致這種錯誤的原因就是使用了強制型別轉換。C++允許型別轉換並能正確處理的是具有繼承關係的型別的物件的型別轉換,這也是多型的基礎。C++編譯器能夠根據標頭檔案中類的宣告在型別轉換時自動調整物件指標的位置,從而能夠正確的實現多型。 然而如果C++編譯器不能根據類宣告推算出型別轉換時的指標調整方式時,如果使用了強制型別轉換,那麼編譯器只是簡單的默默無作為,也就是根本就不調整指標位置,也不會警告開發者。這就導致了問題的發生。 解決思路有三個: (1)不使用強制型別轉換,使用static_cast進行編譯期型別轉換,此時如果C++編譯期不能推算出指標調整演算法,就會報錯,提醒開發者。
這種方法可以提示開發者出現錯誤,但不能解決問題。 (2)不使用強制型別轉換,使用dynamic_cast進行執行期動態型別轉換,這需要開啟編譯器的RTTI,如下所示。
int main(int argc, char** argv)
{
	I1* pI1 = CreateC();
	pI1->vf1();

	I2* pI2 = dynamic_cast<I2*>(pI1);
	pI2->vf2();

	delete pI1;
	return 0;
}
此時,編譯和執行都如預期一樣,完全正確。缺陷就是開啟RTTI會影響程式效能,而且好像VC++6中無法正常工作。 (3)某種程式上學習COM,提供介面查詢功能。
#include <iostream>
#include <hash_map>
using namespace std;

class I1
{
public:
	virtual void vf1()
	{
		cout << "I'm I1:vf1()" << endl;
	}
};

class I2
{
public:
	virtual void vf2()
	{
		cout << "I'm I2:vf2()" << endl;
	}
	virtual void vf3()
	{
		cout << "I'm I2:vf3()" << endl;
	}
};

class C : public I1, public I2
{
private:
	hash_map<string, string> m_cache;
};

I1* CreateC()
{
	return new C();
}

I2* QueryInterface(I1* obj)
{
	C* pC = static_cast<C*>(obj);
	return static_cast<I2*>(pC);
}

I1* QueryInterface(I2* obj)
{
	C* pC = static_cast<C*>(obj);
	return static_cast<I1*>(pC);
}

int main(int argc, char** argv)
{
	I1* pI1 = CreateC();
	pI1->vf1();

	I2* pI2 = QueryInterface(pI1);
	pI2->vf2();

	delete pI1;
	return 0;
}

這種方式,既可以得到正確的執行結果,也不需要使用者呼叫dynamic_cast,所以效果最好。但實現和呼叫都較為麻煩,使得庫的使用不方便。

5 一點感想

(1)C++到處充滿細節,使得開發者必須考慮很多細節,而且編譯器有時候對開發者隱藏了很多東西,有時候又做的不好,使得這個語言做開發不太順手,也許這就是C#,Java盛行的原因,C#中完全不存在上面說的問題,因為C#一定是執行時型別識別的。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ConsoleApplication1
{
    class Program
    {
        static void Main(string[] args)
        {
            I1 pI1 = new C();
            pI1.vf1();
            I2 pI2 = (I2)pI1;
            pI2.vf2();
        }
    }

    interface I1
    {
        void vf1();
    }

    interface I2
    {
        void vf2();
    }

    class C : I1, I2
    {
        public void vf1()
        {
            Console.WriteLine("I'm vf1()");
        }
        public void vf2()
        {
            Console.WriteLine("I'm vf2()");
        }
    }
}
(2)開發庫的時候,對外介面以類的形式是否合適?是否還是以純粹的C函式為介面更簡潔?C++的前途....