1. Giới thiệu
Hãy tưởng tượng bạn có một cái hộp. Nếu đây là cái hộp chứa chính vật thể (ví dụ, hộp có quả táo bên trong), thì nó giống như kiểu giá trị. Dữ liệu nằm trực tiếp trong "hộp" (biến) này.
Mặt khác, hãy tưởng tượng bạn có một tấm danh thiếp với địa chỉ. Bản thân tấm danh thiếp không phải là ngôi nhà, nó chỉ chỉ ra nơi tìm thấy ngôi nhà. Điều này giống như kiểu tham chiếu. Biến trong trường hợp này không chứa dữ liệu, mà là "danh thiếp" – địa chỉ trong bộ nhớ nơi dữ liệu được lưu.
Ví dụ đơn giản:
- int x = 5; // Kiểu giá trị: biến x "chứa" số 5 trực tiếp.
- string name = "Vasya"; // Kiểu tham chiếu: biến name "chứa" tham chiếu đến chuỗi "Vasya", nằm đâu đó trong bộ nhớ.
Ai là ai trong cuộc sống này?
Để bạn dễ hình dung, đây là danh sách tổng quát các loại dữ liệu chính thuộc về loại nào:
Kiểu giá trị (Value Types):- Kiểu nguyên thủy: int, double, float, bool, char, byte, short, long, decimal v.v.
- Struct (struct): Tất cả struct do bạn tự định nghĩa bằng từ khóa struct.
- Enum (enum): Kiểu cho phép định nghĩa tập hợp các hằng số có tên.
- Chuỗi (string): Dù chuỗi có một số đặc điểm riêng (bất biến), nó vẫn là kiểu tham chiếu.
- Tất cả mảng: Ví dụ, int[], string[], YourCustomClass[].
- Tất cả class (class): Tất cả class do bạn tự định nghĩa bằng từ khóa class.
- Delegate (delegate): Kiểu đại diện cho tham chiếu đến phương thức.
- Interface (interface): Dù interface không phải là object, biến kiểu interface có thể chứa tham chiếu đến object thực thi interface đó.
- List, dictionary và các collection khác: Ví dụ, List<T>, Dictionary<TKey, TValue>.
- Nói chung, mọi thứ không phải là struct hoặc enum thì mặc định là kiểu tham chiếu trong C#.
2. Sao chép biến
Đây chính là điểm khác biệt thực tế quan trọng nhất. Khi bạn gán một biến cho biến khác, thực sự cái gì được sao chép?
A) Sao chép kiểu giá trị (Value Type)
Khi bạn sao chép kiểu giá trị, sẽ tạo ra bản sao độc lập hoàn toàn của chính dữ liệu. Giống như bạn photo một tài liệu: thay đổi ở một bản không ảnh hưởng gì đến bản gốc hay các bản khác.
int a = 10;
int b = a; // b giờ cũng là 10, nhưng là "bản sao riêng"
Console.WriteLine($"Giá trị ban đầu: a = {a}, b = {b}"); // Giá trị ban đầu: a = 10, b = 10
b = 15; // Thay đổi b
Console.WriteLine($"Sau khi thay đổi b: a = {a}, b = {b}"); // Sau khi thay đổi b: a = 10, b = 15
Giải thích: Biến b nhận bản sao riêng giá trị 10. Khi b được đổi thành 15, a vẫn giữ giá trị gốc 10. Chúng hoàn toàn độc lập.
B) Sao chép kiểu tham chiếu (Reference Type)
Khi sao chép kiểu tham chiếu, chỉ tham chiếu (tức là "địa chỉ" object trong bộ nhớ) được sao chép. Cả hai biến giờ đều trỏ đến cùng một object. Giống như bạn đưa cho hai người cùng một tấm danh thiếp: cả hai đều biết địa chỉ cùng một ngôi nhà. Nếu một người sơn lại tường nhà, người kia đến cũng sẽ thấy tường đã đổi màu.
Hãy xem ví dụ với mảng, vì nó rất dễ hình dung "ma thuật" của tham chiếu (khác với string có chút đặc biệt).
int[] arr1 = {1, 2, 3};
int[] arr2 = arr1; // arr2 và arr1 trỏ đến cùng một mảng trong bộ nhớ!
// Giá trị ban đầu: arr1[0] = 1, arr2[0] = 1
Console.WriteLine($"Giá trị ban đầu: arr1[0] = {arr1[0]}, arr2[0] = {arr2[0]}");
arr2[0] = 42; // Thay đổi phần tử mảng qua arr2
// Sau khi thay đổi arr2[0]: arr1[0] = 42, arr2[0] = 42
Console.WriteLine($"Sau khi thay đổi arr2[0]: arr1[0] = {arr1[0]}, arr2[0] = {arr2[0]}");
Giải thích: Cả hai biến (arr1 và arr2) đều chứa tham chiếu đến cùng một mảng trong bộ nhớ. Khi bạn thay đổi phần tử arr2[0], bạn thực sự thay đổi chính mảng mà cả hai biến cùng trỏ đến. Vì vậy arr1[0] cũng hiển thị giá trị đã thay đổi.
Chú ý với chuỗi (string)
Chuỗi trong C# là kiểu tham chiếu, nhưng hành xử hơi khác vì bất biến (immutability). Nghĩa là sau khi tạo, chuỗi không thể thay đổi. Bất kỳ thao tác nào "thay đổi" chuỗi (ví dụ, nối chuỗi, Replace()), thực ra tạo chuỗi mới trong bộ nhớ.
string str1 = "Hello";
string str2 = str1; // str2 trỏ đến cùng object "Hello" như str1
// str1 = "Hello", str2 = "Hello"
Console.WriteLine($"Giá trị ban đầu: str1 = \"{str1}\", str2 = \"{str2}\"");
str2 = "Bye"; // Ở đây tạo object MỚI "Bye", và str2 bắt đầu trỏ đến nó
// str1 = "Hello", str2 = "Bye"
Console.WriteLine($"Sau khi thay đổi str2: str1 = \"{str1}\", str2 = \"{str2}\"");
Giải thích: Ban đầu str1 và str2 cùng trỏ đến object "Hello". Khi str2 được gán "Bye", C# không thay đổi object "Hello" hiện có. Thay vào đó, nó tạo object mới "Bye" trong bộ nhớ, và str2 giờ trỏ đến object mới này. str1 vẫn trỏ đến object cũ "Hello". Đây là điểm khác biệt quan trọng, dễ gây nhầm lẫn cho người mới.
3. Bảng so sánh chính
| Đặc điểm | Kiểu Giá Trị (ví dụ, struct, int) |
Kiểu Tham Chiếu (ví dụ, class, string, mảng) |
|---|---|---|
| Cái gì được sao chép | Chính giá trị ("bản photo" dữ liệu) | Tham chiếu đến object ("địa chỉ" trong bộ nhớ) |
| Liên kết giữa các bản sao | Không, các bản sao hoàn toàn độc lập. Thay đổi một bản không ảnh hưởng bản khác. | Có, tất cả tham chiếu trỏ đến cùng một object. Thay đổi object qua một tham chiếu sẽ thấy ở tất cả. |
| Có thể là null? | Không (trừ các kiểu Nullable như int?). Luôn có giá trị. | Có. Có thể trỏ đến "không gì cả" (null). Truy cập object null sẽ gây ra NullReferenceException. |
| Khai báo như thế nào | struct, cũng như các kiểu nguyên thủy (int, bool v.v.), enum | class, interface, delegate, array, string, object |
| Cơ chế giải phóng | Tự động xóa khỏi stack khi ra khỏi phạm vi. | Được dọn dẹp bởi Garbage Collector khi không còn tham chiếu nào nữa. |
4. Ví dụ trong ứng dụng
Giả sử bạn đang phát triển một ứng dụng console đơn giản cho khảo sát người dùng. Chúng ta có struct cho điểm thi và class cho hồ sơ người dùng.
// Kiểu giá trị: struct lưu điểm
struct Score
{
public int Points;
public string Grade; // Thêm cho dễ nhìn
}
// Kiểu tham chiếu: class cho hồ sơ người dùng
class User
{
public string Name;
public Score ExamScore; // struct lồng bên trong
}
Sao chép struct (Score)
Score score1 = new Score { Points = 100, Grade = "A" };
Score score2 = score1; // Sao chép toàn bộ nội dung score1 vào 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("--- Sau khi thay đổi score2 ---");
Console.WriteLine($"score1: Points={score1.Points}, Grade={score1.Grade}"); // score1: Points=100, Grade=A (không đổi!)
Console.WriteLine($"score2: Points={score2.Points}, Grade={score2.Grade}"); // score2: Points=88, Grade=B
Kết quả: score1 vẫn giữ nguyên. Điều này xảy ra vì khi Score score2 = score1; thì nội dung score1 (tất cả trường) được sao chép vào score2. Hai biến giờ có bộ dữ liệu độc lập.
Sao chép class (User)
User u1 = new User
{
Name = "Anna",
ExamScore = new Score { Points = 95, Grade = "A" }
};
User u2 = u1; // Giờ u2 và u1 cùng trỏ đến MỘT người dùng trong bộ nhớ!
Console.WriteLine($"u1: Name={u1.Name}, Score={u1.ExamScore.Points}"); // u1: Name=Anna, Score=95
Console.WriteLine($"u2: Name={u2.Name}, Score={u2.ExamScore.Points}"); // u2: Name=Anna, Score=95
u2.Name = "Ivan"; // Đổi tên qua u2
u2.ExamScore.Points = 60; // Đổi điểm qua u2
u2.ExamScore.Grade = "C";
Console.WriteLine("--- Sau khi thay đổi u2 ---");
Console.WriteLine($"u1: Name={u1.Name}, Score={u1.ExamScore.Points}, Grade={u1.ExamScore.Grade}"); // u1: Name=Ivan, Score=60, Grade=C
Console.WriteLine($"u2: Name={u2.Name}, Score={u2.ExamScore.Points}, Grade={u2.ExamScore.Grade}"); // u2: Name=Ivan, Score=60, Grade=C
Kết quả: u1 cũng bị đổi! Vì u1 và u2 ban đầu cùng trỏ đến cùng một object User trong bộ nhớ. Khi bạn đổi thuộc tính qua u2 (ví dụ, u2.Name = "Ivan";), bạn thực sự đổi chính object đó. Nên khi truy cập u1.Name, bạn thấy giá trị đã đổi. Ngay cả struct lồng bên trong ExamScore cũng đổi cho u1, vì nó là một phần của object User mà cả hai tham chiếu cùng trỏ đến.
5. Chuyện gì xảy ra khi truyền vào hàm
Hiểu cách các kiểu được truyền vào hàm là cực kỳ quan trọng để dự đoán hành vi chương trình.
Truyền kiểu giá trị (Value Type) vào hàm
Khi truyền kiểu giá trị vào hàm mặc định là truyền theo giá trị. Nghĩa là hàm nhận bản sao của biến gốc. Mọi thay đổi bên trong hàm với bản sao này không ảnh hưởng đến biến gốc bên ngoài.
void AddTen(int x)
{
Console.WriteLine($"Trong hàm (trước khi đổi): x = {x}"); // Trong hàm (trước khi đổi): x = 5
x = x + 10; // x giờ là 15, nhưng là bản sao cục bộ
Console.WriteLine($"Trong hàm (sau khi đổi): x = {x}"); // Trong hàm (sau khi đổi): x = 15
// Bản sao cục bộ 'x' sẽ "biến mất" sau khi thoát hàm.
}
int num = 5;
Console.WriteLine($"Trước khi gọi hàm: num = {num}"); // Trước khi gọi hàm: num = 5
AddTen(num);
Console.WriteLine($"Sau khi gọi hàm: num = {num}"); // Sau khi gọi hàm: num = 5 (không đổi!)
Kết quả: Biến num không đổi (5). x trong AddTen là biến hoàn toàn riêng biệt, được khởi tạo bằng bản sao giá trị num.
Truyền kiểu tham chiếu (Reference Type) vào hàm
Khi truyền kiểu tham chiếu vào hàm mặc định cũng là truyền theo giá trị, nhưng là giá trị của tham chiếu, không phải object. Nghĩa là trong hàm bạn nhận được bản sao "danh thiếp" (địa chỉ) đến object gốc. Cả hai tham chiếu (gốc và trong hàm) cùng trỏ đến một object trong bộ nhớ.
void RenameUser(User u)
{
// Trong hàm (trước khi đổi): u.Name = "Olga"
Console.WriteLine($"Trong hàm (trước khi đổi): u.Name = \"{u.Name}\"");
u.Name = "Tên mới"; // Đổi thuộc tính object mà 'u' trỏ đến
// Trong hàm (sau khi đổi): u.Name = "Tên mới"
Console.WriteLine($"Trong hàm (sau khi đổi): u.Name = \"{u.Name}\"");
}
User user = new User { Name = "Olga" };
// Trước khi gọi hàm: user.Name = "Olga"
Console.WriteLine($"Trước khi gọi hàm: user.Name = \"{user.Name}\"");
RenameUser(user);
// Sau khi gọi hàm: user.Name = "Tên mới"
Console.WriteLine($"Sau khi gọi hàm: user.Name = \"{user.Name}\"");
Kết quả: Tên người dùng đã đổi thành "Tên mới". Hàm RenameUser nhận bản sao tham chiếu đến object user. Qua bản sao này, hàm có thể truy cập object gốc trong heap và đổi thuộc tính Name.
Lưu ý quan trọng: Chuyện gì xảy ra nếu trong hàm ta gán object mới cho biến kiểu tham chiếu truyền vào?
void ReassignUser(User u)
{
u = new User { Name = "Người dùng hoàn toàn mới" }; // 'u' giờ trỏ đến object mới
Console.WriteLine($"Trong hàm (sau khi gán lại): u.Name = \"{u.Name}\"");
}
User originalUser = new User { Name = "Người dùng gốc" };
Console.WriteLine($"Trước khi gọi ReassignUser: originalUser.Name = \"{originalUser.Name}\"");
ReassignUser(originalUser);
Console.WriteLine($"Sau khi gọi ReassignUser: originalUser.Name = \"{originalUser.Name}\""); // "Người dùng gốc" - không đổi!
Kết quả: originalUser không đổi! Vì ReassignUser nhận bản sao tham chiếu. Khi u = new User(...) xảy ra trong hàm, biến cục bộ u trỏ đến object hoàn toàn mới. Tham chiếu gốc originalUser vẫn trỏ đến object cũ. Đây là điểm cực kỳ quan trọng!
6. "Nước mắt newbie": lỗi phổ biến và nguyên nhân
Hiểu kiểu tham chiếu và kiểu giá trị có thể khá khó cho người mới. Đây là một số lỗi và hiểu lầm thường gặp:
Nhầm lẫn khi sao chép mảng: Người mới thường nghĩ rằng khi gán arr2 = arr1; sẽ tạo ra bản sao độc lập của mảng. Thực tế, chỉ là hai tham chiếu đến cùng một mảng. Giống như hai tay cầm điều khiển cùng một game: bấm nút ở một tay cầm sẽ ảnh hưởng đến game, và tay cầm kia cũng thấy thay đổi. Để tạo bản sao độc lập, phải clone rõ ràng (ví dụ, int[] arr2 = (int[])arr1.Clone(); hoặc dùng các phương thức Copy).
Mong đợi string "thay đổi theo tham chiếu": Vì string là kiểu tham chiếu, đôi khi nghĩ nó sẽ hành xử như mảng khi thay đổi. Nhưng do bất biến, mọi thao tác "thay đổi" thực ra tạo object string mới. Điều này dễ gây kết quả bất ngờ và có thể không hiệu quả khi thao tác nhiều lần trong vòng lặp (nên dùng StringBuilder).
Quên mất null: Kiểu giá trị (trừ nullable như int?, bool?) luôn có giá trị và không thể là null. Kiểu tham chiếu có thể là null, tức là không trỏ đến object nào. Truy cập thành viên của object null sẽ gây ra NullReferenceException. Luôn kiểm tra biến kiểu tham chiếu có thể null trước khi dùng, nếu có khả năng chưa được gán.
Dùng class thay vì struct cho dữ liệu nhỏ: Đôi khi theo thói quen mọi thứ đều khai báo bằng class. Với dữ liệu nhỏ, đơn giản, đại diện cho một thực thể (ví dụ, điểm Point { X, Y }, màu Color { R, G, B }), struct có thể hiệu quả hơn vì lưu trên stack và sao chép theo giá trị, giảm gánh nặng cho Garbage Collector. Tuy nhiên, struct nên bất biến, nhỏ gọn và không chứa kiểu tham chiếu có thể null hoặc thay đổi trạng thái.
GO TO FULL VERSION