如何在C#中模擬C++的聯合(Union)?[C#, C++] How To Simulate C++ Union In C#?

鴨脖發表於2012-11-03

如何在C#中模擬C++的聯合(Union)?[C#, C++]

How To Simulate C++ Union In C#?

 

Updated on Sunday, December 26, 2004

 

Written by Allen Lee

 

0 如何閱讀本文?

如果你...

  • ...希望瞭解聯合的概念,請閱讀“什麼是聯合?”。
  • ...希望瞭解聯合的記憶體使用情況,請閱讀“聯合的記憶體佈局與記憶體使用情況。”。
  • ...希望瞭解如何在C#中模擬聯合,請閱讀“第一次嘗試:在C#中模擬這種佈局方式。”。
  • ...希望瞭解在C++中使用聯合有哪些要注意的地方,請閱讀“在實際的C++程式碼中,我們是如何使用聯合的?”。
  • ...希望瞭解如何在C#中更好的使用模擬的聯合,請閱讀“第二次嘗試:改進型的聯合模擬。”。
  • ...希望瞭解在C#中使用模擬的聯合有些什麼注意事項,請閱讀“別在模擬的聯合中同時使用值型別和引用型別!”。
  • ...希望瞭解為何我要寫這篇文章,請閱讀“為什麼要在C#裡面模擬這個用處不大的東西?”。

否則...

  • ...你應該從頭到尾閱讀全文。

 

1 什麼是聯合?

聯合(Union)是一種特殊的類,一個聯合中的資料成員在記憶體中的儲存是互相重疊的。每個資料成員都在相同的記憶體地址開始。分配給聯合的儲存區數量是“要包含它最大的資料成員”所需的記憶體數。同一時刻只有一個成員可以被賦給一個值。

下面我們來看看C++中如何表達聯合:

// Code #01
union TokenValue
{
    
char _cval;
    
int _ival;
    
double _dval;
}
;

 

2 聯合的記憶體佈局與記憶體使用情況。

下面我們來考察一下TokenValue的記憶體佈局。

首先,我們使用sizeof運算子來獲取該聯合各個成員的記憶體佔用位元組數:

// Code #02
int _tmain(int argc, _TCHAR* argv[])
{
    cout 
<< "sizeof(char): " << sizeof(char<< endl;
    cout 
<< "sizeof(int): " << sizeof(int<< endl;
    cout 
<< "sizeof(double): " << sizeof(double<< endl;

    
return 0;
}


/*
 * Output:
 * sizeof(char): 1
 * sizeof(int): 4
 * sizeof(double): 8
 *
 
*/

這樣,分配給該聯合的記憶體就是8個位元組。

接著,我們來看看具體使用該聯合時,所分配的記憶體的位元組佔用情況如何:

// Code #03
int _tmain(int argc, _TCHAR* argv[])
{
    TokenValue tv;
    
// [_][_][_][_][_][_][_][_]

    tv._cval 
= 'K';

    
// [X][_][_][_][_][_][_][_]

    tv._ival 
= 1412;

    
// [X][X][X][X][_][_][_][_]

    tv._dval 
= 3.14159;

    
// [X][X][X][X][X][X][X][X]

    
return 0;
}

 

3 第一次嘗試:在C#中模擬這種佈局方式。

在C#中,要指定成員的記憶體佈局情況,我們需要結合使用StructLayoutAttribute特性、LayoutKind列舉和FieldOffsetAttribute特性,它們都位於System.Runtime.InteropServices名稱空間中。

下面我用struct來試著模擬上面的TokenValue聯合:

// Code #04
[StructLayout(LayoutKind.Explicit, Size=8)]
struct TokenValue
{
    [FieldOffset(
0)]
    
public char _cval;

    [FieldOffset(
0)]
    
public int _ival;

    [FieldOffset(
0)]
    
public double _dval;
}

我們知道,聯合的每個資料成員都在相同的記憶體地址開始,通過把[FieldOffset(0)]應用到TokenValue的每一個成員,我們就指定了這些成員都處於同一起始位置。當然,我們得事先告訴.NET這些成員的記憶體佈局由我們來作主,把LayoutKind.Explicit列舉傳遞給StructLayoutAttribute特性的建構函式,並應用到TokenValue,.NET就不會再幹涉該struct的成員在記憶體中的佈局了。另外,我顯式的把TokenValue的大小設定為8位元組,當然,這樣做是可選的。

 

4 在實際的C++程式碼中,我們是如何使用聯合的?

在實際的C++程式碼中,我們應儘量避免讓客戶端直接使用聯合,Code #03就是一個很好的反面例子了。為什麼呢?熟悉C/C++的開發人員都知道,聯合提供我們這樣一個節省空間的儲存方式,是要我們付出一定的代價的。這個代價就是程式碼的安全性,不恰當地使用聯合可能會導致程式崩潰的。

由於每一次只有一個聯合成員處於啟用狀態,如果我們不小心或者因為其它原因使用處於休眠狀態的成員,輕則得到錯誤的結果,重則整個程式中止。請看下面的程式碼:

// Code #05
union TokenValue
{
    
char _cval;
    
int _ival;
    
double _dval;
    
char* _sval;
}
;

int _tmain(int argc, _TCHAR* argv[])
{
    TokenValue tv;
    tv._cval 
= 'K';
    cout 
<< tv._cval << endl;    // Line #01
    cout << tv._ival << endl;    // Line #02
    cout << tv._dval << endl;    // Line #03
    cout << tv._sval << endl;    // Line #04

    
return 0;
}

這裡的TokenValue比起Code #01的僅僅多了一個_sval,它是C風格的字串,實質上,它是指向字串的第一個字元的指標,它佔用4位元組的記憶體空間。

當程式執行到Line #04時,就會出現Unhandled Exception,程式中止,並指出_sval的值非法(即所謂的“野指標”)。程式無法把它的值輸出控制檯,然而,Line #01 ~ Line #03都能輸出,只是Line #02和Line #03所輸出的值是錯誤的而已。

實際的應用中,我們一般不會看到如此低階且顯而易見的錯誤,但複雜的實際應用中,不恰當地使用聯合的確會為我們帶來不少的麻煩。

 

5 第二次嘗試:改進型的聯合模擬。

一般情況下,聯合作為一種內部資料的儲存手段,沒有必要讓客戶端對其有所瞭解,更沒必要讓客戶端直接使用它。為了使我們的聯合模擬用起來更安全,我們需要對它進行一番包裝:

// Code #06
class Program
{
    
static void Main(string[] args)
    
{
        Token t = 
new Token();

        Console.WriteLine(t);
        Console.WriteLine(t.GetTokenValue());

        t.SetTokenValue('K');
        Console.WriteLine(t);
        Console.WriteLine(t.GetTokenValue());
    }

}


public struct Token
{
    
private TokenValue tv;
    
private TokenKind tk;

    
public void SetTokenValue(char c)
    
{
        tk = TokenKind.CharValue;
        tv._cval = c;
    }


    
public void SetTokenValue(int i)
    
{
        tk = TokenKind.IntValue;
        tv._ival = i;
    }


    
public void SetTokenValue(double d)
    
{
        tk = TokenKind.DoubleValue;
        tv._dval = d;
    }


    
public object GetTokenValue()
    
{
        
switch (tk)
        
{
            
case TokenKind.CharValue:
                
return tv._cval;
            
case TokenKind.IntValue:
                
return tv._ival;
            
case TokenKind.DoubleValue:
                
return tv._dval;
            
default:
                
return "NoValue";
        }

    }


    
public override string ToString()
    
{
        
switch (tk)
        
{
            
case TokenKind.CharValue:
                
return tv._cval.ToString();
            
case TokenKind.IntValue:
                
return tv._ival.ToString();
            
case TokenKind.DoubleValue:
                
return tv._dval.ToString();
            
default:
                
return "NoValue";
        }

    }


    [StructLayout(LayoutKind.Explicit, Size = 8)]
    
private struct TokenValue
    
{
        [FieldOffset(0)]
public char _cval;
        [FieldOffset(0)]
public int _ival;
        [FieldOffset(0)]
public double _dval;
    }


    
private enum TokenKind
    
{
        NoValue,
        CharValue,
        IntValue,
        DoubleValue
    }

}


/* 
 * Output:
 * NoValue
 * NoValue
 * K
 * K
 *
 */

由於Token是值型別,例項化時,對應的成員(tv和tk)會自動被賦予與之對應的零值。此時,tv._cval為'\0'、tv._ival和tv._dval均為0(實質上它們是同一個值在不同的型別中的表現)。而tk也被自動賦予0:

tk = 0;

這裡,你無需進行強型別轉換,0是任何列舉的預設初始值,.NET會負責把0轉換成對應的列舉型別。例如,你可以:

// Code #07
System.DayOfWeek d = 0;
Console.WriteLine(d);

該程式碼能正確輸出Sunday——一個星期的第一天(西方習慣),也是該列舉的第一個成員。

一般情況下,0對應著列舉的第一個成員(除非你在定義列舉的時候,把第一個成員指定為別的值,併為別的成員賦予0值)。這樣,我們就不難看出程式碼的輸出是合理的,而且程式碼本身也是安全的。

 

6 別在模擬的聯合中同時使用值型別和引用型別!

到目前為止,我們所模擬的聯合中,所有的成員都是值型別,如果我們為它加入一個引用型別,例如String呢?

// Code #08
[StructLayout(LayoutKind.Explicit, Size=8)]
struct TokenValue
{
    [FieldOffset(0)]
    
public char _cval;

    [FieldOffset(0)]
    
public int _ival;

    [FieldOffset(0)]
    
public double _dval;

    [FieldOffset(0)]
    
public string _sval;
}

這樣,Code #06的程式碼執行時就會提示出錯:

Could not load type 'TokenValue' from assembly 'UnionLab, Version=1.0.1820.28531, Culture=neutral, PublicKeyToken=null' because it contains an object field at offset 0 that is incorrectly aligned or overlapped by a non-object field.

TokenValue初始化的時候,_cval、_ival和_dval都能正確的被賦予對應的零值,而這些零值也能被統一起來(別的值就不行了)。但_sval不同,它是引用型別,如果沒有顯示初始化為某個有意義的值,它將被賦予null值!這個null值跟之前的有意義的零值是不能被統一起來的!所以,要麼你就去掉這個_sval,要麼就重新定義它的起始位置(當然,你也得去掉Size=8!),但這樣一來,TokenValue就不再稱得上聯合的模擬了。

在C++中,我們可以直接使用指標來解決這個問題,如Code #05,但C#中,問題就會變得有點辣手。如果你有興趣的話,可以使用不安全程式碼(Unsafe code)來試著解決,但這樣一來,你的程式碼又會引入一些新的問題。

 

7 為什麼要在C#裡面模擬這個用處不大的東西?[NEW]

相信很多人都有這樣一個疑問:為什麼要在C#裡面模擬這個用處不大的東西?就我個人來說,我始終堅信事物的存在必定有它的理由,否則就不會存在。其實,聯合在我們平時的編碼中的確很少用到,但在某些情況下,我們必須使用它!.NET為我們提供巨大的便利的同時,也不忘讓我們能夠與非託管程式碼互動。你知道,早期的Win32 API使用C來完成的,這裡面就有很多函式的引數是以聯合的形式表達的,要在C#中跟這些API互動,我們就得“尊重”原函式的用法約束。

 

8 終點與起點的交界處。

回顧整個探索旅程,我們為了使用聯合節省空間的優勢,開始了這個模擬的探索,然而,為了彌補聯合的不足,我們對這個模擬進行了一番包裝,增加了不少額外的程式碼,直到後來,又發現了在這個模擬中同時使用值型別的成員和引用型別的成員所引發的問題,我們一直都沒有停止過探索和思考。正如馬斯洛的需要層次理論所描述的,人只要低層次的需要被滿足,馬上就會轉向更高的需要層次,一級一級的,直到攀上最高峰為止。

關於在C#中模擬C++的聯合這個話題,我並沒有在本文中給予你一個完整的展示,相反,我為你展示的僅僅是一個探索的起點,希望為你帶來一絲靈感,讓你根據自己的實際情況來定製你的探索旅程。Have a good trip!


參考資料:

相關文章