[C#.NET 拾遺補漏]14:使用結構體實現共用體

精緻碼農發表於2021-01-15

在 C 和 C# 程式語言中,結構體(Struct)是值型別資料結構,它使得一個單一變數可以儲存多種型別的相關資料。在 C 語言中還有一種和結構體非常類似的語法,叫共用體(Union),有時也被直譯為聯合或者聯合體。而在 C# 中並沒有共用體這樣一個定義,本文將介紹如何使用 C# 實現 C 語言中的共用體。

理解 C 語言的共用體

在 C 語言中,共用體是一種特殊的資料型別,允許你使用相同的一段記憶體空間儲存不同的成員資料。光看定義有點抽象,我們來看一個 C 語言的共用體示例:

#include <stdio.h>

union data{
    int n;
    char ch;
    short m;
};

int main(){
    union data a;
    printf("%d, %d\n", sizeof(a), sizeof(union data) );
    a.n = 0x40;
    printf("%X, %c, %hX\n", a.n, a.ch, a.m);
    a.ch = '9';
    printf("%X, %c, %hX\n", a.n, a.ch, a.m);
    a.m = 0x2059;
    printf("%X, %c, %hX\n", a.n, a.ch, a.m);
    a.n = 0x3E25AD54;
    printf("%X, %c, %hX\n", a.n, a.ch, a.m);

    return 0;
}

執行結果:

4, 4
40, @, 40
39, 9, 39
2059, Y, 2059
3E25AD54, T, AD54

要想理解上面的輸出結果,就得了解共用體各個成員在記憶體中的分佈。此示例中的 data 各個成員在記憶體中的分佈示意圖如下:

也就是說共用體的所有成員佔用的是同一段記憶體,所佔記憶體等於最長的成員佔用的記憶體,修改一個成員會影響其它所有成員。而結構體的各個成員佔用的是各自不同的記憶體,所佔記憶體大於等於所有成員佔用的記憶體的總和(成員之間可能會存在縫隙),成員相互之間沒有影響。這是共用體和結構的主要區別。

使用 C# 實現共用體

和 C 語言不同的是,C# 中沒有共用體的定義。那在 C# 中如何來實現這種定義呢?

C# 不僅可以實現共用體,而且可以實現比 C 語言更強大的共用體。C 語言的共用體每個成員在共用的記憶體中都必須從相同的起始位置開始儲存,而在 C# 中可以指定各成員的起始位置(相對偏移)。好處是,不僅可以節省記憶體空間,還可以實現一些自動轉換操作。

以 IP 地址的儲存為例,IP 地址是以 4 段數字來表示的(如 192.168.1.10),每一段是一個位元組(Byte),長度是 2^8,最大值是 255。我們可以用很多型別來表示 IP 地址,比如字串、整型、自定義類和結構等。但如果我們有時要訪問或修改其中一段,怎樣儲存最為方便呢?

我們可以使用 C# 的顯示佈局結構體來實現類似 C 語言中的共用體,以方便靈活地操作 IP 地址的每一段。實現方式如下:

using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Explicit)]
public struct IpAddress
{
    // FieldOffset 表示偏移的位置(以位元組為單位)
    // sizeof(int) = 4, sizeof(byte) = 1
    [FieldOffset(0)] public int Address;
    [FieldOffset(0)] public byte Byte1;
    [FieldOffset(1)] public byte Byte2;
    [FieldOffset(2)] public byte Byte3;
    [FieldOffset(3)] public byte Byte4;

    public IpAddress(int address) : this()
    {
        // 給 Address 賦值時,所有成員的值都會自動被修改
        Address = address;
    }

    public override string ToString() => $"{Byte1}.{Byte2}.{Byte3}.{Byte4}";
}

這裡我們使用了 StructLayout 特性標註了 IpAddress,宣告其記憶體分佈是顯示(Explicit)的,然後使用 FieldOffset 特性來標註成員在共用記憶體中相對起始位置的偏移量(以位元組為單位)。

如此我們就用 C# 實現了和 C 語言一樣的共用體。可能你不能馬上體會這樣實現的妙處,讓來我們來看一個應用場景。

假設我要在 IP 段內隨機生成一個 IP,比如前兩段不變,後兩段隨機,形如:192.163.X.X。使用上面定義好的“共用體”,我們可以這樣做:

var ip = new IpAddress(new Random().Next());
Console.WriteLine($"{ip} = {ip.Address}");
ip.Byte1 = 192;
ip.Byte2 = 168;
Console.WriteLine($"{ip} = {ip.Address}");

輸出結果:

47.29.249.122 = 2063146287
192.168.249.122 = 2063182016

這樣不僅節省記憶體,而且可以很靈活方便地讀取和修改 IP 中的某一段。由於成員 Address 和其它成員共用記憶體,所以修改一個成員,其餘就自動修改。

共用體作為另一個共用體的成員

既然“共用體”是值型別,那麼共用體自然也可以作為作為另一個共用體的成員。讓我們來看一個較為複雜的例子,使用共用體實現由協議、IP 和埠三部分組成的服務端地址的表示,形如:協議://IP:埠。

using System;
using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Explicit)]
public struct IpAddress
{
    [FieldOffset(0)] public int Address;
    [FieldOffset(0)] public byte Byte1;
    [FieldOffset(1)] public byte Byte2;
    [FieldOffset(2)] public byte Byte3;
    [FieldOffset(3)] public byte Byte4;

    public IpAddress(int address) : this()
    {
        Address = address;
    }

    public override string ToString() => $"{Byte1}.{Byte2}.{Byte3}.{Byte4}";
}

public enum Protocol : byte { http, https, ftp, sftp, tcp };

[StructLayout(LayoutKind.Explicit)]
public struct Server
{
    [FieldOffset(0)] public IpAddress Address;
    [FieldOffset(4)] public ushort Port;
    [FieldOffset(6)] public Protocol Protocol;
    [FieldOffset(0)] public long Payload;

    public Server(IpAddress addr, ushort port, Protocol prot) : this()
    {
        Address = addr;
        Port = port;
        Protocol = prot;
    }

    public Server(long payload)
    {
        // 引數長度可能不足填滿每個成員,所以這裡先對成員設初始值
        Address = new IpAddress(0);
        Port = 80;
        Protocol = Protocol.http;

        // 填值
        Payload = payload;
    }

    public Server Copy() =>  new Server(Payload);

    public override string ToString() => $"{Protocol}://{Address}:{Port}";
}

我們來用一段測試程式碼驗證一下這個 Server 結構體的記憶體使用情況:

var ip = new IpAddress(new Random().Next());
Console.WriteLine($"Size: {Marshal.SizeOf(ip)} bytes. Value: {ip.Address} = {ip}");

var s1 = new Server(ip, 8080, Protocol.https);
var s2 = new Server(s1.Payload);
s2.Address.Byte1 = 100;
s2.Protocol = Protocol.ftp;
Console.WriteLine($"Size: {Marshal.SizeOf(s1)} bytes. Value: {s1.Address} = {s1}");
Console.WriteLine($"Size: {Marshal.SizeOf(s2)} bytes. Value: {s2.Address} = {s2}");

輸出結果:

Size: 4 bytes. Value: 2102736192 = 64.53.85.125
Size: 8 bytes. Value: 64.53.85.125 = https://64.53.85.125:8080
Size: 8 bytes. Value: 100.53.85.125 = ftp://100.53.85.125:8080

示例中,IP 地址偏移 0 位元組,長度為 4 位元組;埠號偏移 4 位元組,長度為 2 位元組;協議偏移 6 位元組,長度為 1 位元組。總長度應為 4+2+1=7 位元組,但實際列印出來卻是 8 位元組,請問是為什麼?

參考:https://bit.ly/3qmH92V

相關文章