程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> .NET網頁編程 >> C# >> 關於C# >> 拷貝構造和賦值操作符,C#和本機C++代碼的互用性

拷貝構造和賦值操作符,C#和本機C++代碼的互用性

編輯:關於C#

我有一個簡單的 C++ 問題。我想讓我的拷貝構造函數和賦值操作做同樣的事情。你能告訴我最佳實現方法嗎?

Shadi Hani乍一看, 這似乎是一個答案簡單的簡單問題:寫一個調用 operator= 的構造函數不就行了:CFoo::CFoo(const CFoo& obj)
{
   *this = obj;
}

或者,寫一個公用的拷貝方法,拷貝構造函數和 operator= 都調用這個方法也行。就像這樣:

CFoo::CFoo(const CFoo& obj)
{
  CopyObj(obj);
}
CFoo& CFoo::operator=(const CFoo& rhs)
{
  CopyObj(rhs);
  return *this;
}

對於大多數類來說,這是行得通的,但還有些 特殊情況需要考慮。如果你的類包含有數據成員是另一個類的實例會怎樣呢?為了弄清楚這個問題,我寫了一個測試程序如 Figure 1 所示。 它有一個主類 CMainClass,它包含另一個類 CMember 的實例。兩個類都有拷貝構造函數和賦值操作,用 CMainClass 的拷貝構造函數調用 operator=,如下面的代碼段所示。代碼中使用 printf 語句是為了顯示何時調用了哪個方法。為了運行構造函數,cctest 程序首先用缺省構 造函數創建 CMainClass 實例,然後用拷貝構造函數創建另一個實例:

CMainClass obj1;
CMainClass obj2 (obj1);

如果你編譯並運行 cctest,當構造 obj2 時,你會看到下面的 printf 信息:

CMember: default ctor
CMainClass: copy-ctor
CMainClass: operator=
CMember: operator=

成員對象 m_obj 被初始化了兩次!第一次是缺 省構造,第二次是賦值時再次被初始化。嘿,這是怎麼回事?

在 C++ 中,賦值和拷貝是不同的,因為拷貝構造函數是對未初始化的內 存進行初始化操作,而賦值是對現有的已經初始化的對象進行操作。如果你的類包含其它的類實例作為數據成員,那麼拷貝構造在調用 operator=之前必須首先構造這些數據成員。其結果是致使這些成員就像 cctest 那樣被初始化兩次,明白了嗎?當你用賦值操作而不是初始化 例程進行成員初始化時,缺省構造函數也會發生同樣的事情。例如:

CFoo::CFoo()
{
  m_obj = DEFAULT;
}

與下面代碼相對:

CFoo::CFoo() : m_obj(DEFAULT)
{
}

使用賦值方式,m_obj 被初始化兩 次,而用初始化例程語法,m_obj 只被初始化一次。所以,要如何避免拷貝構造期間額外的初始化呢?當它與你的代碼重用初衷相抵觸時, 最 好的解決俄u方法就是分開實現拷貝構造和賦值操作,即便它們做同樣的事情。從拷貝構造中調用 operator= 肯定能行得通,但不是最有效率 的實現。我對初始化的建議是:

CFoo::CFoo(const CFoo& rhs) : m_obj(rhs.m_obj) {}

現在,主拷貝構造用初 始化例程調用成員對象的拷貝構造,並且 m_obj 只被其拷貝構造初始化一次。通常情況下,拷貝構造應該調用其成員的拷貝構造。賦值也是如 此。並且,它也同樣適用於基類:派生類的拷貝構造和賦值操作應該調用對應的基類方法。當然,有時因為一些具體情況,可能你的做法會有 所不同——這裡我所描述的是通用規則,只有在你遇到強制性原因時才會破壞這個規則。如果你要在基本對象被初始化之後完成一 些公共任務,可以將它們放到一個公共的初始化方法中,並在構造函數和 operator= 中調用。

你能告訴我如何從 C# 中調用 Visual C++ 類,對此我需要什麼樣的語法?

Sunil Peddi 我有一個用 C#(用戶界面)和經典的 C++(業務邏輯)寫的應用程序。現在我需要從某 個用 C++ 寫的 DLL中調用一個函數(或方法),該函數在一個用 Visual C++ .NET 編寫的 DLL 中。而這個 Visual C++ .NET DLL 又要調用 另一個用 C# 寫的 DLL。Visual C++ .NET DLL 相當於一個代理。這樣做可行嗎?我能用 LoadLibrary 調用 Visual C++ .NET DLL 輸出的函 數,可以得到返回值,但當我試圖向 Visual C++ .NET DLL 中的函數傳遞參數時,我遇到如下錯誤:Run-Time Error Check Failure #0—The value of ESP was not properly saved
across a function call. This is usually a result of calling a function
declared with one calling convention with a function pointer declared
with a different calling convention.

我如何解決這個問題?

Giuseppe Dattilo

我得到許多關於 .NET 框架和本機 C++ 之間的互操作問題, 所以我不介意再次復習這個(well-covered)主題。有兩條路可走:從 C++ 中調用框架;或者從框架調用 C++。我不打算在此涉及 COM 的互 用性,我把它放在以後單獨的一期專欄裡討論。

讓我先從最簡單的一種開始:從 C++ 調用框架。從 C++ 程序中調用框架最簡單,最輕 松的方法是使用托管擴展(Managed Extensions)。這是微軟專用的 C++ 語言擴展,它被設計專門用來調用框架,只要包含兩個頭文件即可, 然後象使用 C++ 類一樣來使用它們。下面是一個非常簡單的調用框架 Console 類的 C++ 程序:

#using <mscorlib.dll>
#using <System.dll> // implied
using namespace System;
void main()
{
  Console::WriteLine("Hello, world");
}

為了使用托管擴展,你只需引入 <mscorlib.dll> 和你打算使用的框架類所附著的程序集。不要忘了用 /clr 編譯。

cl /clr hello.cpp

你的 C++ 代碼可以或多或少地使用托管類,就像普通的 C++ 類一樣。例如,你可以用操作符  new 創建框架對象,並用 C++ 指針語法存取它們,象下面這樣:

DateTime d = DateTime::Now;
String* s = String::Format("The date is {0}\n", d.ToString());
Console::WriteLine(s);
Console::WriteLine(s->Length);

這裡,String s 被聲明為 String 指針,因為 String::Format 返回一個新的 String 對象。

“Hello,world”和日期/時間程序似乎很簡單——它們確實簡單 ——不過要記住不管你的程序多復雜,使用的類和 .NET 程序集有多少,其基本思路是一樣的:用 <mscorlib.dll> 以及其 它所需的程序集,然後用 new 創建托管對象,並使用指針語法來存取它們。

以上討論的是如何從 C++ 調用框架。那麼反過來從框架調 用 C++ 該如何做呢?根據你是否想調用外部 C函數或 C++ 類成員函數,有兩個選擇。我們還是首先從最簡單的開始:從 .NET 調用 C 函數。 最輕松的方法是使用 P/Invoke。使用 P/Invoke,你將外部函數聲明為某個類的靜態成員,用 DLLImport 來指定外部 DLL 中的函數。在 C# 是這樣做的:

public class Win32 {
  [DllImport("user32.dll", CharSet=CharSet.Auto)]
   public static extern int MessageBox(IntPtr hWnd, String text, String caption, int type);
}

這段代碼告訴編譯器 MessageBox 是 user32.dll 中的一個函數,參數是 IntPtr (HWND),兩個 String 和一個 int。這樣你便可以在 C# 程序中調用:

Win32.MessageBox(0, "Hello World", "Platform Invoke Sample", 0);

當然,使用 MessageBox 你不必通過 P/Invoke,因為 .NET 框架已經具備一個 MessageBox 類,但是大量的 API 函數框架是不直接支持的,調用這些函數 時需要 P/Invoke。並且,你還可以用 P/Invoke 調用自己 DLL中輸出的 C 函數。盡管在例子中我用的是 C#,但 P/Invoke 支持任何基於 .NET 的語言,如:Visual Basic .NET 或 JScript.NET。函數名稱都相同,只是語法有差別。

注意我用 IntPtr 來聲明 HWND。盡管使 用 int 也可能行,但對於任何象 HWND,HANDLE 或 HDC 這樣的句柄,你應該始終用 IntPtr,根據平台的不同,IntPtr 會默認為 32 位或 64 位,所以你根本不用擔心句柄的大小。

DllImport 具備各種修飾符,你可以用來說明有關引入函數的細節。在上面的例子中, CharSet=CharSet.Auto 告訴框架根據目標操作系統的具體情況,將 String 作為 Unicode 或 Ansi 來傳遞。另一個鮮為人知的修飾符是 CallingConvention,回想一下在 C 語言中,我們會有不同的調用規范,通過這些規范來說明編譯器如何在函數間通過堆棧傳遞參數以及返回 值的規則。DllImport 默認的 CallingConvention 是 CallingConvention.Winapi。實際上,這是一個偽規范,對於目標平台來說,它用默認 規范;例如, Windows 平台上的 StdCall(被調用者負責清除堆棧)以及 Windows CE .NET 上的 CDecl(調用者負責清除堆棧)。CDecl 還 可以用於帶有可變參數的函數,如:printf。

Giuseppe 碰到的就是調用規范的問題。C++ 還使用第三種調用規范:即 thiscall。用這 種調用規范,編譯器借助硬件的 ECX 寄存器來向不帶可變參數的類成員函數傳遞“this”指針。我們對 Giuseppe 程序的細節並不 了解,從出錯信息來分析,他企圖從使用 StdCall 規范的 C# 程序中調用使用 thiscall 規范的 C++ 函數——這樣當然不行啦!

除了調用規范,另一個從框架調用 C++ 方法時存在的互用性問題是鏈接:C 和 C++ 使用不同形式的鏈接,因為 C++ 需要名字修飾來 支持函數重載。這就是為什麼當在 C++ 程序中聲明 C 函數時,你得用 extern "C":這樣編譯器才不會修飾函數名。在 Windows 裡,整個 windows.h 文件(現在是 winuser.h)都包含在 extern "C" 裡。

雖然使用 P/Invoke 和 DllImport 以及完全修 飾過的名稱和 CallingConvention=ThisCall 也有辦法直接調用某個 DLL 中的 C++ 成員函數,但如果你是一個正常的人,不要去這麼做。從 托管代碼中 調用 C++ 類的正確方法——第二種選擇——是在托管包裝器中包裝你的 C++ 類。如果你的類很多,包裝可 能很繁瑣,但別無選擇。假設你有一個 C++ 類 CWidget 並想包裝它,以便 .NET 客戶端能使用它,其基本套路如下:

public __gc class Widget
{
private:
  CWidget* m_pObj; // ptr to native object
public:
  Widget() { m_pObj = new CWidget; }
  ~Widget() { delete m_pObj; }
  int Method(int n) { return m_pObj->Method(n); }
  // etc.
};

任何類都是這種模式:

寫一個托管類(__gc)保存一個指向本地類的指針;

編寫構造函數和 析構函數分配和銷毀對象實例;

編寫對應於 C++ 成員函數的包裝器方法;

你不必包裝所有的成員函數,僅僅包裝那些打算暴露 給托管環境的函數即可。

Figure 2 所示的是一個簡單完整而具體的例子。CPerson 是一個本地 C++ 類,包含人名,有兩個成員函數: GetName 和 SetName,後者用於修改人名。Figure 3 所示的是 CPerson 的托管包裝器。在這個例子中,我將 Get/SetName 轉換為屬性,這樣 一來,基於 .NET 的程序員就可以用屬性語法。在 C# 中是這樣用的:

// C# client
MPerson.Person p = new MPerson.Person("Fred");
String name = p.Name;
p.Name = "Freddie";

用不用屬性純粹是編程 風格問題,我完全可以照搬本地 C++ 類的做法也輸出兩個方法:GetName 和 SetName。但屬性給人的感覺更像 .NET。包裝器類就是一個程序 集,只不過與本地 DLL 鏈接。這是托管擴展一個很酷的特性之一:你可以直接與本地 C/C++ 代碼鏈接。如果你下載並編譯我的 CPerson 例子 源代碼,你會發現 makefile 產生兩個單獨的 DLLs:person.dll 和 mperson.dll,前者實現常規的本地 DLL,後者是包裝前者的托管程序集 。還有兩個測試程序:testcpp.exe,此為調用 person.dll 的本地 C++ 程序;testcs.exe,此為用 C# 編寫的程序,它調用托管包裝器 mperson.dll(它又調用本地 person.dll)。

以上我用非常簡單的例子著重說明了托管和本地之間跨邊界通訊的僅有的幾種方法。如 Figure 4 所示:

Figure 4 互用性途徑

如果 C++ 類太復雜,你碰到的最大的互用性問題將會是本地和托管類型之間的參數轉換問題,這個過程稱為封送(marshaling)。托管擴展所做的 一個令人贊譽的工作是使這一過程盡可能輕松(例如,自動轉換原始數據類型和字符串 String),但有時你必須了解自己正在做什麼。

例如,你不能在沒有固定(pinning)住托管對象或嵌入對象的前提下,將其地址傳遞給本地函數。那是因為托管對象存在於托管堆中 ,垃圾收集器在托管堆中可以隨意重整對象。如果垃圾收集器移動某個對象,它能更新所有針對該對象的托管引用——但它對托管 環境以外的原始指針一無所知。那就是 __pin 的作用之所在;它告訴垃圾收集器:不要移動這個對象。對於字符串來說,框架有一個專門的函 數 PtrToStringChars,返回一個被固定住的本地字符指針。(順便提一下,對於那些好奇者來說,PtrToStringChars 是到目前為止定義在 <vcclr.h>文件中的唯一一個函數)。其代碼如下:

// PtrToStringChars, from vcclr.h
// get an interior gc pointer to the first character contained in a
// System::String object
//
inline const System::Char * PtrToStringChars(const System::String *s) {
  const System::Byte *bp = reinterpret_cast<const System::Byte *> (s);
  if( bp != 0 ) {
    unsigned offset = System::Runtime::CompilerServices::
       RuntimeHelpers::OffsetToStringData;
    bp += offset;
  }
  return reinterpret_cast<const System::Char*>(bp);
}

我在 MPerson 中使用 PtrToStringChars 來設置 Name,詳細代碼參見 Figure 3。

指針固定並不是你將遇到的僅有的互用性問題。如果你要處理數組,引用,結構和回調,或者存取某個對象中的嵌入對象,還會碰到其它的 問題。這是一些將來要討論的更高級的技術,如:StructLayout,框入/框出(boxing),__value 類型等等。你還需要專門的代碼來處理異常 (本地或托管)以及回調/委托。但不要讓這些戶用性細節遮住了大方向。首先確定你的調用方式(是從托管調用本地還是從本地調用托管), 如果你是從托管調用本地,是使用 P/Invoke 還是包裝器。

Visual Studio 2005 中(有些人已經開始用beta版了),托管擴展已更名 並升級到 C++/CLI。你可以把 C++/CLI 看成是 Managed Extensions Version 2,或者是 Managed Extensions 演變成的一個什麼。這個改變 幾乎都是語法上的,雖然也有一些重要的語義變化。總體上講,C++/CLI 是設計用來突出而不是模糊托管和本地對象間的差異。使用托管對象 的指針語法是明智的想法,但最終也許做的有些過於明智,因為它淡化了托管和本地對象之間的重要差異。C++/CLI 引入了一個處理托管對象 的關鍵概念,CLI 處理托管對象時使用 ^(讀作 hat)來代替 C 語言的指針語法:// handle to managed string
String^ s = gcnew String;

正像你已經明確注意到的,還有一個 gcnew 操作符用以來表示你是在托管堆中分配對象,而不是在本地分配。這樣 做有一個額外的好處是 gcnew 不會與 C++ 的 new 發生沖突,它能被重載或者甚至被重定義成一個宏。C++/CLI 有許多其它很棒的特性,專門 用來使互用性盡可能簡單明了。

本文配套源碼

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved