1. 前言
想像你有一個盒子。如果這個盒子裡直接裝著東西(比如裡面有蘋果),這就像是值型別。資料本身就直接在這個「盒子」(變數)裡。
另一種情況,想像你有一張名片,上面寫著地址。名片本身不是房子,它只是指向哪裡可以找到房子。這就像是參考型別。變數這時候裝的不是資料本身,而是「名片」——記憶體裡資料的地址。
最簡單的例子:
- int x = 5; // 值型別:變數 x 直接「存」著數字 5。
- string name = "瓦西亞"; // 參考型別:變數 name 存的是指向「瓦西亞」這個字串的參考,這個字串在記憶體某個地方。
誰是誰?
讓你不會迷路,這裡有個常見資料型別的分類表:
值型別(Value Types):- 基本型別: int, double, float, bool, char, byte, short, long, decimal 等等。
- 結構(struct): 你用 struct 宣告的所有自訂結構。
- 列舉(enum): 可以定義一組有名字的常數。
- 字串(string): 雖然字串有點特別(它是 immutable),但它還是參考型別。
- 所有陣列: 例如 int[], string[], YourCustomClass[]。
- 所有類別(class): 你用 class 宣告的所有自訂類別。
- 委派(delegate): 代表方法參考的型別。
- 介面(interface): 雖然介面本身不是物件,但介面型別的變數可以存放實作該介面的物件參考。
- List、Dictionary 及其他集合: 例如 List<T>, Dictionary<TKey, TValue>。
- 總之,除了 struct 跟 enum 以外,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]}");
說明: 兩個變數(arr1 跟 arr2)都存著同一個陣列的參考。你改 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}\"");
說明: 一開始 str1 跟 str2 都指向 "Hello"。當 str2 指定成 "Bye",C# 不是去改 "Hello" 物件,而是新建一個 "Bye",str2 指向它。str1 還是指向原本的 "Hello"。這點常常讓新手搞混。
3. 主要差異表
| 特性 | 值型別 (例如 struct, int) |
參考型別 (例如 class, string, 陣列) |
|---|---|---|
| 複製什麼 | 值本身(資料的「影本」) | 物件的參考(記憶體「地址」) |
| 副本間關聯 | 沒有,副本完全獨立。改一個不會影響另一個。 | 有,所有參考都指向同一個物件。改物件任何地方大家都看得到。 |
| 可以是 null 嗎? | 不行(除了 Nullable 型別像 int?)。一定有值。 | 可以。可以指向「沒有東西」(null)。如果你對 null 物件操作,會噴 NullReferenceException。 |
| 宣告方式 | struct,還有所有基本型別(int, bool 等)、enum | class、interface、delegate、array、string、object |
| 清除機制 | 離開作用域自動從 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 也跟著變了!因為 u1 跟 u2 一開始就指向同一個 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。x 在 AddTen 裡是完全獨立的副本,初始化時複製了 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 或狀態被改的問題)。
GO TO FULL VERSION