More Effective C# 04.區分實值與參考型別 More Effective C# 04.區分實值與參考型別(Distinguish Between Value Types and Reference Types)

Published on Sunday, November 3, 2024

這個做法在討論 Value Types 和 Reference Types 兩種型別中複製與傳遞的差異性與它們的使用場合。

當我們要建立新的型別時,第一件要做的事就是挑選 class 或是 struct,如果選錯的話之後要修改會大大影響使用到這個型別的使用者。

首先看看這個例子,這裡 MyData 型別使用 struct 建立,所以它是個 Value Types,並且透過 Foo 方法將物件進行回傳,最終會將回傳的物件完整複製一份到 v 區域變數內, 這也是 Value Types 的特性。

void Main()
{
	var a = new MyClass();

	MyData v = a.Foo();
	v.Value = 2;
	Console.WriteLine(v.Value);
	Console.WriteLine(a.FooValue());
}

public class MyClass
{
	private MyData myData;
	public MyData Foo() => myData;
	public int FooValue() => myData.Value;

	public MyClass()
	{
		myData = new MyData(1);
	}
}

public struct MyData
{
	public int Value { get; set; } = 1;

	public MyData(int value)
	{
		Value = value;
	}
}

但如果只要將 struct 改成 class,那麼就會變成 Reference Types,Foo 方法回傳的就是物件的參考,最終複製到 v 區域變數內的就只是一份參考, 可以理解成匯出一份參考到 v 中,這也代表你能透過 v 修改 myData 實例內部的狀態這個做法違反了封裝的原則。

public class MyData
{
	public int Value { get; set; } = 1;

	public MyData(int value)
	{
		Value = value;
	}
}

接下來看看這個例子,內部使用了 MemberwiseClone 方法將 myData 實例複製一份到新的到 myDataClone 上, 這樣區域變數 v 獲得的就是 myDataClone 的參考,所以修改 v 變數時不會修改到原始的 myData 實例, 最後修改沒問題在把值更新回去 myData 實例,但這種做法會在 heap 上額外建立一個物件。

void Main()
{
	var c = new MyClass();

	MyData v = c.Foo();
	v.Value = 2;
	c.Update(v);
	Console.WriteLine(c.FooValue());
	Console.WriteLine(c.BarValue());
}

public class MyClass
{
	private MyData myData;
	private MyData myDataClone;
	public MyData Foo() => myDataClone;
	public int FooValue() => myData.Value;
	public int BarValue() => myDataClone.Value;

	public MyClass()
	{
		myData = new MyData(1);
		myDataClone = myData.Clone();
	}
	
	public void Update(MyData m)
	{
		myData = m;
	}
}

public class MyData
{
	public int Value { get; set; } = 1;

	public MyData(int value)
	{
		Value = value;
	}

	public MyData Clone()
	{
		return (MyData)this.MemberwiseClone();
	}
}

現在來看一下兩種型別是如何在記憶體中儲存的,假設 MyType 是一個 Value Types 那麼這段程式碼就會執行一次記憶體分配(Allocating memory) 並分配兩倍 MyType 大小的記憶體。

但如果是 Reference Types 則會執行三次記憶體分配,分別是一次 C 物件與兩次內部的 MyType 物件。

這兩者之間的差異是保存 Value Types 的變數是真正保存實際的值,而 Reference Types 則是保存參考。

public class C
{
	private MyType a = new MyType();
	private MyType b = new MyType();
}

C cThing = new C();

更詳細的說明可以參考這段程式碼,假設 MyType 是一個 Value Types 那麼這段程式碼就會分配一次大小為 100 倍 MyType 大小的記憶體, 如果是 Reference Types 則會執行一次記憶體分配,並且陣列中的所有元素都是 null,但如果你把所有 100 個元素都初始化,則會執行 101 次記憶體分配, 跟 Value Types 的一次相比之下,Reference Types 需要更多時間並且造成 heap fragments。

MyType[] arrayOfTypes = new MyType[100];

也就是說如果你建立的型別確定是用來保存資料的,那就應該使用 Value Types;


Summary

資料儲存的場合建立成 Value Types,與行為相關的場合定義成 Reference Types,這樣在複製資料物件的時候就能更加安全,同時 Value Types 又能將資料保存在更快的 stack 記憶體,使用 Value Types 的場合目的要很明確並且要知道它的優缺點,如果有模糊不知道該怎麼選擇的場合, 那應該選擇 Reference Types 比較不會出錯。