CodeGym /課程 /C# SELF /參考型別與值型別

參考型別與值型別

C# SELF
等級 12 , 課堂 3
開放

1. 前言

想像你有一個盒子。如果這個盒子裡直接裝著東西(比如裡面有蘋果),這就像是值型別。資料本身就直接在這個「盒子」(變數)裡。

另一種情況,想像你有一張名片,上面寫著地址。名片本身不是房子,它只是指向哪裡可以找到房子。這就像是參考型別。變數這時候裝的不是資料本身,而是「名片」——記憶體裡資料的地址。

最簡單的例子:

  • int x = 5; // 值型別:變數 x 直接「存」著數字 5。
  • string name = "瓦西亞"; // 參考型別:變數 name 存的是指向「瓦西亞」這個字串的參考,這個字串在記憶體某個地方。

誰是誰?

讓你不會迷路,這裡有個常見資料型別的分類表:

值型別(Value Types):
  • 基本型別: int, double, float, bool, char, byte, short, long, decimal 等等。
  • 結構(struct): 你用 struct 宣告的所有自訂結構。
  • 列舉(enum): 可以定義一組有名字的常數。
參考型別(Reference Types):
  • 字串(string): 雖然字串有點特別(它是 immutable),但它還是參考型別。
  • 所有陣列: 例如 int[], string[], YourCustomClass[]
  • 所有類別(class): 你用 class 宣告的所有自訂類別。
  • 委派(delegate): 代表方法參考的型別。
  • 介面(interface): 雖然介面本身不是物件,但介面型別的變數可以存放實作該介面的物件參考。
  • List、Dictionary 及其他集合: 例如 List<T>, Dictionary<TKey, TValue>
  • 總之,除了 structenum 以外,C# 預設都是參考型別。

2. 變數複製

這裡就是實務上最重要的差別。當你把一個變數指定給另一個,實際上複製了什麼?

A) 複製值型別(Value Type

複製值型別時,會產生完全獨立的副本。就像影印一份文件:你改其中一份,另一份完全不受影響。


    int a = 10;
int b = a; 					// b 現在也是 10,但這是「自己的副本」
Console.WriteLine($"初始值: a = {a}, b = {b}"); // 初始值: a = 10, b = 10

b = 15; 					// 改 b
Console.WriteLine($"改變 b 之後: a = {a}, b = {b}"); // 改變 b 之後: a = 10, b = 15

說明: 變數 b 得到自己的 10。當 b 改成 15,a 還是原本的 10。兩個完全獨立。

B) 複製參考型別(Reference Type

複製參考型別時,只會複製參考,也就是「地址」。兩個變數現在都指向同一個物件。就像兩個人拿到同一張名片:他們都知道同一個房子的地址。只要一個人去那個房子刷油漆,另一個人去也會看到牆變色。

我們用陣列來看,這最容易看出「參考」的魔法(字串有點特別,後面再說)。


int[] arr1 = {1, 2, 3};
int[] arr2 = arr1; // arr2 跟 arr1 指向同一個記憶體裡的陣列!

// 初始值: arr1[0] = 1, arr2[0] = 1
Console.WriteLine($"初始值: arr1[0] = {arr1[0]}, arr2[0] = {arr2[0]}"); 

arr2[0] = 42; // 用 arr2 改陣列的元素

// 改 arr2[0] 之後: arr1[0] = 42, arr2[0] = 42
Console.WriteLine($"改 arr2[0] 之後: arr1[0] = {arr1[0]}, arr2[0] = {arr2[0]}"); 

說明: 兩個變數(arr1arr2)都存著同一個陣列的參考。你改 arr2[0],其實就是改那個陣列本身,所以 arr1[0] 也跟著變。

字串的特例(string

C# 的字串是參考型別,但因為不可變(immutable),行為有點不一樣。意思是,字串一旦建立就不能改。任何看起來會「改變」字串的操作(像是串接、Replace()),其實都會產生新的字串


string str1 = "Hello";
string str2 = str1; // str2 跟 str1 指向同一個 "Hello" 物件

// str1 = "Hello", str2 = "Hello"
Console.WriteLine($"初始值: str1 = \"{str1}\", str2 = \"{str2}\""); 

str2 = "Bye"; // 這裡會產生新的 "Bye" 物件,str2 開始指向它

// str1 = "Hello", str2 = "Bye"
Console.WriteLine($"改 str2 之後: str1 = \"{str1}\", str2 = \"{str2}\""); 

說明: 一開始 str1str2 都指向 "Hello"。當 str2 指定成 "Bye",C# 不是去改 "Hello" 物件,而是新建一個 "Bye",str2 指向它。str1 還是指向原本的 "Hello"。這點常常讓新手搞混。

3. 主要差異表

特性 值型別
(例如 struct, int)
參考型別
(例如 class, string, 陣列)
複製什麼 值本身(資料的「影本」) 物件的參考(記憶體「地址」)
副本間關聯 沒有,副本完全獨立。改一個不會影響另一個。 有,所有參考都指向同一個物件。改物件任何地方大家都看得到。
可以是 null 嗎? 不行(除了 Nullable 型別像 int?)。一定有值。 可以。可以指向「沒有東西」(null)。如果你對 null 物件操作,會噴 NullReferenceException
宣告方式 struct,還有所有基本型別(int, bool 等)、enum classinterfacedelegatearraystringobject
清除機制 離開作用域自動從 stack 移除。 沒人參考時由垃圾回收(Garbage Collector)清掉。

4. 實務範例

假設我們要寫一個簡單的使用者問卷 console 應用程式。有一個 struct 存考試分數,還有一個 class 存使用者 profile。

// 值型別:存分數的 struct
struct Score
{
    public int Points;
    public string Grade; // 加這個讓例子更明顯
}

// 參考型別:使用者 profile 的 class
class User
{
    public string Name;
    public Score ExamScore; // 裡面有 struct
}

複製 struct(Score

Score score1 = new Score { Points = 100, Grade = "A" };
Score score2 = score1; // score1 的內容全部複製到 score2
Console.WriteLine($"score1: Points={score1.Points}, Grade={score1.Grade}"); // score1: Points=100, Grade=A
Console.WriteLine($"score2: Points={score2.Points}, Grade={score2.Grade}"); // score2: Points=100, Grade=A

score2.Points = 88;
score2.Grade = "B";

Console.WriteLine("--- 改 score2 之後 ---");
Console.WriteLine($"score1: Points={score1.Points}, Grade={score1.Grade}"); // score1: Points=100, Grade=A(沒變!)
Console.WriteLine($"score2: Points={score2.Points}, Grade={score2.Grade}"); // score2: Points=88, Grade=B

結果: score1 沒有被改到。因為 Score score2 = score1; 時,score1 的內容(所有欄位)都複製到 score2。兩個變數現在各自有自己的資料。

複製 class(User

User u1 = new User 
{ 
    Name = "安娜", 
    ExamScore = new Score { Points = 95, Grade = "A" } 
};
User u2 = u1; // u2 跟 u1 都指向同一個 User 物件!
Console.WriteLine($"u1: Name={u1.Name}, Score={u1.ExamScore.Points}"); // u1: Name=安娜, Score=95
Console.WriteLine($"u2: Name={u2.Name}, Score={u2.ExamScore.Points}"); // u2: Name=安娜, Score=95

u2.Name = "伊萬"; // 用 u2 改名字
u2.ExamScore.Points = 60; // 用 u2 改分數
u2.ExamScore.Grade = "C";

Console.WriteLine("--- 改 u2 之後 ---");
Console.WriteLine($"u1: Name={u1.Name}, Score={u1.ExamScore.Points}, Grade={u1.ExamScore.Grade}"); // u1: Name=伊萬, Score=60, Grade=C
Console.WriteLine($"u2: Name={u2.Name}, Score={u2.ExamScore.Points}, Grade={u2.ExamScore.Grade}"); // u2: Name=伊萬, Score=60, Grade=C

結果: u1 也跟著變了!因為 u1u2 一開始就指向同一個 User 物件。你用 u2 改屬性(像 u2.Name = "伊萬";),其實就是改那個物件本身。所以 u1.Name 也會看到新值。連裡面的 struct ExamScore 也會一起變,因為它是 User 物件的一部分,兩個參考都指向同一個。

5. 傳進方法時會發生什麼事

搞懂型別怎麼傳進方法,對預測程式行為超重要。

傳值型別(Value Type)進方法

預設是傳值。也就是方法拿到的是副本。你在方法裡怎麼改這個副本,都不會影響外面的原本變數。

void AddTen(int x)
{
    Console.WriteLine($"方法內(改前): x = {x}"); // 方法內(改前): x = 5
    x = x + 10; // x 變 15,但這是本地副本
    Console.WriteLine($"方法內(改後): x = {x}"); // 方法內(改後): x = 15
    // 這個本地 x 離開方法就消失了
}

int num = 5;
Console.WriteLine($"呼叫方法前: num = {num}"); // 呼叫方法前: num = 5
AddTen(num);
Console.WriteLine($"呼叫方法後: num = {num}"); // 呼叫方法後: num = 5(沒變!)

結果: 變數 num 還是 5。xAddTen 裡是完全獨立的副本,初始化時複製了 num 的值。

傳參考型別(Reference Type)進方法

預設也是傳值,但複製的是參考的值,不是物件本身。也就是說,方法裡拿到的是「名片」的副本,這兩張名片都指向同一個物件。

void RenameUser(User u)
{
    // 方法內(改前): u.Name = "奧莉加"
    Console.WriteLine($"方法內(改前): u.Name = \"{u.Name}\""); 
    u.Name = "新名字"; // 改物件的屬性
    // 方法內(改後): u.Name = "新名字"
    Console.WriteLine($"方法內(改後): u.Name = \"{u.Name}\""); 
}

User user = new User { Name = "奧莉加" };
// 呼叫方法前: user.Name = "奧莉加"
Console.WriteLine($"呼叫方法前: user.Name = \"{user.Name}\""); 
RenameUser(user); 
// 呼叫方法後: user.Name = "新名字"
Console.WriteLine($"呼叫方法後: user.Name = \"{user.Name}\""); 

結果: 使用者名字變成「新名字」了。RenameUser 拿到的是 user 的參考副本。透過這個副本,方法可以改 heap 裡的原本物件的 Name 屬性。

補充: 如果你在方法裡重新指定一個新物件給參考型別的參數會怎樣?

void ReassignUser(User u)
{
    u = new User { Name = "全新使用者" }; // u 現在指向新物件
    Console.WriteLine($"方法內(重新指定後): u.Name = \"{u.Name}\"");
}

User originalUser = new User { Name = "原本使用者" };
Console.WriteLine($"呼叫 ReassignUser 前: originalUser.Name = \"{originalUser.Name}\"");
ReassignUser(originalUser); 
Console.WriteLine($"呼叫 ReassignUser 後: originalUser.Name = \"{originalUser.Name}\""); // "原本使用者" 沒變!

結果: originalUser 沒變!因為 ReassignUser 拿到的是參考的副本。當你在方法裡 u = new User(...),只是讓本地變數 u 指向新物件,外面的 originalUser 還是指向原本那個。這點超重要!

6. 「新手的眼淚」:常見錯誤與原因

搞懂參考型別跟值型別對新手來說真的不簡單。這裡列幾個常見的錯誤跟誤解:

陣列複製搞混: 很多新手以為 arr2 = arr1; 會產生獨立的陣列副本。其實只是兩個參考指向同一個陣列。就像兩個搖桿控制同一台遊戲機:你按哪個搖桿都會影響遊戲畫面。要真的複製一份陣列,要用 clone(像 int[] arr2 = (int[])arr1.Clone(); 或用 Copy 方法)。

以為字串會「參考傳遞」: 因為 string 是參考型別,有人以為它會像陣列一樣改了就全改。其實因為不可變,任何「改變」操作都會產生新字串物件。這常常讓人意外,也會讓你在迴圈裡一直操作字串時效能很差(這時要用 StringBuilder)。

忘記 null 除了 nullable 型別(int?bool?)以外,值型別一定有值,永遠不會是 null。參考型別可以是 null,也就是沒指向任何物件。你如果對 null 物件操作,會遇到超經典的 NullReferenceException。只要有可能是 null,用之前一定要檢查。

小資料用 class 而不是 struct: 有些人習慣什麼都用 class。其實對於小型、簡單、代表一個整體的資料(像 Point { X, Y }Color { R, G, B }),用 struct 會更有效率,因為它存在 stack,複製時是值傳遞,減少垃圾回收負擔。不過 struct 最好要 immutable、很小,而且不要包參考型別(不然還是會有 null 或狀態被改的問題)。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION