Trong bài này chúng ta sẽ tìm hiểu:

  • Kiểu tham trị (value type) và kiểu tham chiếu (reference type)
  • ref và out
  • box và unbox

Kiểu tham trị và kiểu tham chiếu

Khi chúng ta khai báo một biến kiểu tham trị (hầu hết các kiểu dữ liệu sơ cấp như kiểu int, double, char, v.v. đều là kiểu tham trị ngoại trừ kiểu string), trình biên dịch sẽ phát sinh mã để cấp phát một vùng nhớ đủ lớn để chứa giá trị tương ứng. Ví dụ khai báo biến x kiểu int:


int x;

Trình biên dịch sẽ phát sinh mã cấp phát biến x một vùng nhớ kích cỡ 4 byte (32 bit). Nếu gán x một giá trị:


x = 5;

giá trị 5 sẽ được đặt trong vùng nhớ vừa được cấp phát:

Khi chúng ta khai báo một biến kiểu tham chiếu (như kiểu string, array, class,…) thì trình biên dịch phát sinh mã để cấp phát một vùng nhớ đủ lớn để chứa địa chỉ của vùng nhớ chứa giá trị thực của biến. Ví dụ chúng ta khai báo biến kiểu string:


string str;

str = "Hello there!";

lúc này sẽ có hai vùng nhớ, vùng nhớ chứa địa chỉ của vùng nhớ chứa giá trị Hello there! (ví dụ 3850) và vùng nhớ chứa giá trị Hello there!:

Nếu hình dung khái niệm này tương tự khái niệm con trỏ trong C, chúng ta có thể trực quan như sau:

Bây giờ chúng ta sẽ khai báo thêm biến y kiểu int và gán x đến y:


int x;

x = 5;

int y;

y = x;

x++;

lúc này biến y chứa một bản sao giá trị của biến x, những thay đổi của biến x (x++) sẽ không ảnh hưởng đến biến y.

Chúng ta cũng khai báo thêm biến strref  kiểu string như sau:


string str;

str = "Hello there!";

string strref;

strref = str;

Lúc này hai vùng nhớ chứa địa chỉ của strrefstr sẽ chứa cùng địa chỉ – là địa chỉ của vùng nhớ chứa Hello there!. Có thể hình dung về kiểu tham chiếu và tham trị từ các ví dụ trên một cách trực quan như sau:

Một minh hoạ khác về kiểu tham chiếu (giả sử dấu @ là địa chỉ tham chiếu)

Giá trị null (null values) và kiểu có thể null (nullable types)

Khi khai báo biến, các thực hành tốt nhất là khai báo kết hợp khởi tạo giá trị cho biến đó. Các ví dụ sau khai báo các biến kiểu tham trị (x) và kiểu tham chiếu (st):


int x = 0; // kiểu tham trị

Student st = new Student("Minh");

C# hỗ trợ giá trị null có thể được dùng để khởi tạo các biến kiểu tham chiếu, ví dụ:


Student st = null;

Bản thân null là kiểu tham chiếu nên không thể được dùng để khởi tạo cho các biến kiểu tham trị, ví dụ sau sẽ phát sinh lỗi:

Biến x là kiểu tham trị (int) nên không thể được gán giá trị null, tuy nhiên, C# cho phép một biến kiểu tham trị có thể được gán đến giá trị null bằng cách dùng kí hiệu ? sau kiểu dữ liệu. Ví dụ sau là khai báo hợp lệ:


int? x = null;

kiểu intint? đều là kiểu tham trị nhưng các biến kiểu int? có thể được gán đến null. Ví dụ sau kiểm tra biến x (ở trên) có nhận giá trị null hay không – giống cách kiểm tra một biến kiểu tham chiếu:


if(x == null)

….

Kiểu dữ liệu tham trị theo sau là dấu ? (int?, char?, double?,…) được gọi là các kiểu dữ liệu có thể null (nullable types).

Các biến có kiểu dữ liệu có thể null có thể được gán đến các biến hay các giá trị kiểu tham trị, các ví dụ sau là hợp lệ:


int? x = 3;

int y = 5;

x = y;

tuy nhiên, một biến kiểu tham trị không thể được gán đến một biến kiểu có thể null, ví dụ sau không hợp lệ:


y = x; //lỗi

Thông báo lỗi khi viết lệnh trong Visual Studio:

Thuộc tính HasValue và Value

Đối với các biến có kiểu dữ liệu là kiểu có thể null, chúng ta có thể dùng thuộc tính HasValue – để kiểm tra nó có phải là giá trị null hay không – và thuộc tính Value – để lấy giá trị khác null. Ví dụ sau cho thấy sự khác biệt giữa hai biến kiểu tham trị và kiểu có thể null.

Nếu biến i kiểu tham trị, sẽ không có hai thuộc tính HasValueValue:

Nếu biến i là kiểu có thể null

Đoạn mã sau minh hoạ cách sử dụng hai thuộc tính HasValueValue:


int? i = null;

if (!i.HasValue)

i = 5;

MessageBox.Show(i.Value.ToString());// 5

Đoạn mã trên không mang nhiều ý nghĩa nhưng chúng ta sẽ thấy vai trò của HasValueValue khi gặp những kiểu dữ liệu phức tạp hơn như kiểu liệt kê, kiểu cấu trúc, …

ref và out

Chúng ta gọi một phương thức có tham số bằng cách chuyển các đối số tương ứng đến nó. Lúc này các tham số sẽ được khởi tạo với các bản sao của đối số và phương thức sẽ làm việc với các bản sao này chứ không phải giá trị gốc. Xem ví dụ sau:


static void DoIncrement(int param)

{

param++;

}

static void Main()

{

int arg = 2;

DoIncrement(arg);

Console.WriteLine(arg); // kết quả là 2 chứ không phải 3

}

Giá trị gốc của arg là 2. Phương thức DoIncrement chỉ làm việc với bản sao của arg, không ảnh hưởng đến giá trị gốc nên kết quả vẫn là 2.

C# cung cấp từ khoá ref để phương thức có thể làm việc với giá trị gốc của đối số thay vì giá trị bản sao.Ví dụ trên để hiển thị kết quả là 3 có thể viết lại như sau:


static void DoIncrement(ref int param)

{

param++;

}

static void Main()

{

int arg = 2;

DoIncrement(ref arg);

Console.WriteLine(arg); // kết quả là 3

}

Cần chú ý rằng, biến arg phải được khởi tạo (trong ví dụ trên giá trị khởi tạo của arg là 2) trước khi được chuyển thành đối số của DoIncrement. Nếu chúng ta không khởi tạo arg trước khi gọi phương thức DoIncrement, trình biên dịch sẽ báo lỗi

C# cũng cung cấp từ khoá out có chức năng và cách dùng tương tự từ khoá ref nhưng out buộc chúng ta phải khởi tạo đối số ngay trong phương thức. Ví dụ sau sẽ bị lỗi:


static void DoIncrement(out int param)

{

param++;  // lỗi vì param chưa được khởi tạo

}

static void Main()

{

int arg = 2;

DoIncrement(out arg);

Console.WriteLine(arg);

}

Cách bộ nhớ máy tính được tổ chức

Bộ nhớ máy tính có thể được tổ chức theo hai hình thức là stack và heap. Một số trường hợp dùng heap và stack:

– Tất cả các kiểu tham trị (value types) đều được tạo trên stack, trong khi các kiểu tham chiếu (kể cả kiểu nullable) được tạo trên heap (mặc dù bản thân tham chiếu được tạo trên stack).

– Khi một phương thức được gọi, bộ nhớ được yêu cầu cho các tham số hay các biến cục bộ của phương thức phải được lấy ra từ stack. Khi phương thức hoàn thành, bộ nhớ sẽ được giải phóng.

– Khi một đối tượng được tạo bằng từ khoá new, bộ nhớ được yêu cầu để tạo đối tượng lấy từ heap. Bộ nhớ này được giải phóng khi tham chiếu cuối cùng của đối tượng biến mất. Bộ nhớ heap  không phải vô hạn. Khi heap cạn kiệt, toán tử new sẽ phát sinh ngoại lệ OutOfMemoryException và đối tượng sẽ không được tạo.

Xét đoạn mã sau minh hoạ cách tổ chức bộ nhớ stack và heap:


void Method(int param)

{

Circle c;

c = new Circle(param);

...

}

Giả sử giá trị được gán đến tham số param là 42. Khi phương thức Method được gọi, một khối bộ nhớ (đủ kích cỡ cho kiểu int) được yêu cầu từ stack và được khởi tạo giá trị 42. Khi phương thức thực thi, nhờ lệnh Circle c, một khối bộ nhớ khác được cấp phát từ heap để chứa tham chiếu của đối tượng nhưng không được khởi tạo. Kế tiếp, nhờ lệnh new và phương thức constructor Circle, một khối bộ nhớ dùng để chứa đối tượng được cấp phát từ heap. Một tham chiếu đến đối tượng được chứa trong biến c. Các bước thực thi trên có thể được trực quan hoá như sau:

Lớp System.Object

Là một trong những kiểu tham chiếu quan trọng nhất trong .NET Framework. Tất cả các lớp đều là kiểu đặc biệt của lớp System.Object và có thể dùng lớp System.Object để tạo một biến có thể tham chiếu đến bất kỳ kiểu tham chiếu nào. C# cung cấp từ khoá object như là một nặc danh (alias) cho lớp System.Object.

Ví dụ sau minh hoạ biến c kiểu Circle và o kiểu object cùng tham chiếu đến đối tượng Circle:


Circle c;

c = new Circle(42);

object o;

o = c;

Hình ảnh trực quan cho quá trình thực thi của các đoạn mã sau:

Boxing

Như đã đề cập ở trên, biến kiểu object có thể tham chiếu đến bất kỳ đối tượng kiểu tham chiếu nào và biến kiểu object cũng có thể tham chiếu đến kiểu tham trị. Xét ví dụ sau:


int i = 42;

object o = i;

Biến i là kiểu tham trị và nó tồn tại trên stack. Nếu tham chiếu của o tham chiếu trực tiếp đến i thì nó tham chiếu đến stack. Tuy nhiên, tất cả các tham chiếu phải tham chiếu đến các đối tượng trên heap.  Việc tạo các tham chiếu đến các phần tử trên stack là không được phép vì có thể gây ra một số vấn đề bảo mật tiềm năng. Vì vậy, thay vì tham chiếu trực tiếp đến i, runtime sẽ cấp phát một vùng nhớ từ heap để chứa một bản sao của i và sau đó tham chiếu đối tượng o đến vùng nhớ này. Quá trình này có thể trực quan như sau:

Quá trình chuyển đổi từ kiểu giá trị đến kiểu object hay kiểu giao diện được thực thi bởi kiểu giá trị này gọi là boxing.

Unboxing

Chúng ta không thể truy cập đến một giá trị kiểu int bằng cách tham chiếu biến o kiểu object như lệnh sau:


object o;

int i = o;

Lệnh trên sẽ báo lỗi:

Để khắc phục lỗi trên chúng ta có thể sử dụng ép kiểu (casting). Đoạn mã trên có thể điều chỉnh lại như sau:


object o = 42;

int i = (int)o;

hay có thể viết:


int i = 42;

object o = i;

int i = (int)o;

trong đoạn mã trên trình biên dịch sẽ xác nhận chúng ta ép kiểu biến o từ object sang kiểu int. Nếu trình biên dịch kiểm tra biến o tham chiếu đến kiểu không phù hợp (không phải kiểu int)  thì sẽ phát sinh ngoại lệ InvalidCastException.

Quá trình chuyển đổi từ kiểu object đến kiểu giá trị hay từ kiểu giao diện đến kiểu giá trị thực hiện giao diện đó gọi là unboxing. Minh hoạ trực quan quá trình unboxing của ví dụ trên:

Trường hợp phát sinh ngoại lệ:

Ép kiểu an toàn

Khi ép kiểu, nếu runtime phát hiện kiểu của đối tượng trong bộ nhớ không khớp với kiểu chúng ta muốn chuyển sang thì sẽ phát sinh ngoại lệ InvalidCastException. Ví dụ:

C# cung cấp hai toán tử hữu ích cho việc ép kiểu một cách an toàn, thay vì nắm bắt ngoại lệ phát sinh, là isas.

Toán tử is

Toán tử is giúp chúng ta xác định kiểu của object có phải là kiểu chúng ta mong đợi không. Hai toán hạng của toán tử is là đối tượng (kiểu object) bên trái và kiểu dữ liệu bên phải. Nếu đối tượng được tham chiếu trên heap có kiểu xác định, is sẽ trả về true, ngược lại sẽ trả về false.

Ví dụ trên có thể dùng toán is như sau:


int i;

Circle c = new Circle(42);

object o = c;

if (o is int) // nếu o là kiểu int

i = (int)o;

else

MessageBox.Show("Casting is fail!");

Kết quả ví dụ trên là thông điệp Casting is fail!

Ví dụ khác:


object o = 42;

if (o is int)

{

i = (int)o;

MessageBox.Show(i.ToString());

}

else

MessageBox.Show("Casting is fail!");

Kết quả ví dụ này sẽ là 42.

Toán tử as

Chức năng toán tử as hoàn toàn giống toán tử is và toán tử as cũng có hai toán hạng là đối tượng (kiểu object) bên trái và kiểu dữ liệu bên phải. Điểm khác biệt là toán tử as sẽ trả về giá trị nếu thành công và ngược lại sẽ trả về null. Toán tử as có thể trả về null nên kiểu dữ liệu khi dùng với as sẽ là kiểu tham chiếu hay kiểu có thể null. Ví dụ dùng toán tử as:


int? i;

Circle c = new Circle(42);

object o = c;

i = o as int?;

if (i!=null)

{

MessageBox.Show(i.ToString());

}

else

MessageBox.Show("Casting is fail!");

Kết quả là thông điệp Casting is fail!

Xem xét ví dụ khác:


int? i;

object o = 42;

i = o as int?;

if (i!=null)

{

MessageBox.Show(i.ToString());

}

else

MessageBox.Show("Casting is fail!");

Kết quả là 42

Học C# và WPF >