Menu

Struct trong C#: kiểu giá trị, struct và class, khi nào nên dùng

Cách struct hoạt động trong C#: ngữ nghĩa giá trị và sao chép khi gán, lỗi khi sửa phần tử List<T>, quy tắc hàm khởi tạo, so sánh bằng, boxing, readonly struct và record struct, cùng bảng so sánh struct và class kèm lời khuyên khi nào struct là lựa chọn đúng.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Struct là một kiểu giá trị do bạn tự định nghĩa. Nó trông giống một lớp, có trường, thuộc tính, phương thức và hàm khởi tạo, nhưng một biến kiểu struct chứa dữ liệu trực tiếp thay vì một tham chiếu tới đối tượng. Chỉ một khác biệt đó thôi đã thay đổi cách phép gán, lời gọi phương thức, so sánh bằng và null hoạt động.

Ngữ nghĩa giá trị: phép gán sao chép

Các kiểu số có sẵn, bool, char, DateTime, TimeSpan và Guid đều là struct. Struct của bạn cũng hoạt động y như vậy:

Đầu ra:

struct: s1.X = 1, s2.X = 50
class:  c1.X = 50, c2.X = 50
after Move: s1.X = 1, c1.X = 150

Với struct, s2 là một bản sao độc lập, và Move cũng làm việc trên bản sao riêng của nó. Để một phương thức thay đổi được struct của nơi gọi, hãy truyền theo tham chiếu bằng ref (xem ref và out). Để tránh sao chép một struct lớn mà không cho phép thay đổi, C# 7.2 bổ sung tham số in.

Những hệ quả khác của việc là kiểu giá trị:

  • Một biến struct không bao giờ là null. PointS p = null; không biên dịch được. Dùng PointS? (kiểu giá trị nullable) khi "không có giá trị" là điều có ý nghĩa.
  • Một struct chưa khởi tạo có toàn bộ bằng 0: trường số là 0, trường bool là false, trường tham chiếu là null.
  • Struct không thể kế thừa từ struct hay class khác, và không gì có thể kế thừa từ struct. Struct có thể cài đặt interface.
  • Biến struct cục bộ thường nằm trên stack hoặc bên trong đối tượng chứa nó, nên tạo một struct không cấp phát trên heap.

Cái bẫy List chứa struct: CS1612

Vì đọc một struct tạo ra một bản sao, dòng trông vô hại này lại không biên dịch được:

var points = new List<PointS> { new PointS(1, 2) };
points[0].X = 10;
// error CS1612: Cannot modify the return value of 'List<PointS>.this[int]' because it is not a variable

Indexer của List<T> là một phương thức trả về bản sao của phần tử. Gán X trên bản sao tạm thời đó sẽ không thay đổi gì, nên trình biên dịch chặn bạn lại. Lỗi tương tự xuất hiện khi một thuộc tính trả về struct: order.Location.X = 10. Hãy sao chép, sửa, rồi ghi lại:

Đầu ra:

35
7

Mảng là ngoại lệ: array[0] là chính phần tử, không phải bản sao. Rắc rối lặp đi lặp lại này là lý do cho lời khuyên quen thuộc: hãy làm struct bất biến. Nếu struct không thể bị sửa, thì cũng chẳng có gì để mất khi sửa một bản sao.

Hàm khởi tạo và giá trị mặc định

Từ C# 7 đến C# 9, quy tắc cho hàm khởi tạo của struct rất chặt:

  • Bạn không thể khai báo hàm khởi tạo không tham số. new PointS() luôn tồn tại và đặt mọi trường về 0.
  • Hàm khởi tạo bạn khai báo phải gán mọi trường (và auto-property) trước khi kết thúc.
  • Không được dùng giá trị khởi tạo trường (public int X = 1;) cho các trường instance.

Đầu ra:

19.90 EUR
0.00 (none)
0.00 (none)
0.00 (none)

Money cũng cho thấy vì sao giá trị mặc định quan trọng: một Money toàn số 0 có currency là null, và code của bạn phải xử lý trường hợp này, vì mảng, default(T) và các trường chưa khởi tạo đều tạo ra giá trị như vậy.

Các phiên bản mới hơn đã nới lỏng những quy tắc này:

// C# 10: explicit parameterless constructors and field initializers
public struct Settings
{
    public int Retries = 3;
    public Settings() { }
}

// C# 11: fields you do not assign in a constructor are zeroed automatically,
// instead of being a compile error.

Hàm khởi tạo không tham số của C# 10 chạy với new Settings() nhưng không chạy với default(Settings) hay các phần tử mảng, vốn vẫn toàn số 0. Sự tách biệt này dễ gây bất ngờ, nên hãy dùng cẩn thận.

readonly struct (C# 7.2)

Đánh dấu chính struct là readonly khiến trình biên dịch bắt buộc tính bất biến: mọi trường phải là readonly và mọi auto-property chỉ có get.

public readonly struct Temperature
{
    public double Celsius { get; }
    public Temperature(double celsius) { Celsius = celsius; }

    public Temperature WarmerBy(double delta) => new Temperature(Celsius + delta);   // returns a new value
}

Ngoài việc thể hiện rõ ý định, nó còn giúp hiệu năng: khi một struct không readonly được lưu trong trường readonly hoặc truyền làm tham số in, trình biên dịch sao chép nó trước mỗi lời gọi phương thức (vì không thể biết phương thức có sửa nó hay không). Một readonly struct không cần những bản sao phòng thủ như vậy. Trên C# 7.0 bạn vẫn có thể làm mọi trường thành readonly, như Money làm với các thuộc tính chỉ có get: cách đó cho bạn tính bất biến, nhưng không bớt được các bản sao, vì trình biên dịch chỉ tin một struct được khai báo readonly.

So sánh bằng

Mặc định, Equals trên struct so sánh từng trường một, đúng hành vi giá trị mà bạn muốn. Nhưng phần cài đặt mặc định (ValueType.Equals) có thể dùng reflection và chậm, còn toán tử == thì hoàn toàn không được định nghĩa: a == b trên struct của bạn là lỗi CS0019. Hãy cài đặt cả hai khi struct sẽ được so sánh:

Đầu ra:

True
True
True

Cài đặt IEquatable<T> quan trọng với các collection: HashSet<T>, Dictionary<TKey, TValue> và List<T>.Contains gọi thẳng Equals(GridCell) thay vì boxing từng giá trị để gọi Equals(object). Record struct (bên dưới) tự sinh toàn bộ phần này cho bạn.

Boxing

Chuyển một struct sang object hoặc sang một kiểu interface sẽ boxing nó: runtime sao chép giá trị vào một đối tượng mới trên heap. Từ đó hộp (box) và bản gốc độc lập với nhau:

Đầu ra:

2
0

Mỗi lần boxing tốn một lần cấp phát, đó là lý do các collection không generic cũ như ArrayList chậm với kiểu giá trị và vì sao generic thay thế chúng. Điều này cũng có nghĩa là một struct có thể thay đổi, khi được truy cập qua interface, sẽ bị sửa trong hộp chứ không phải bản gốc, thêm một lý do nữa để giữ struct bất biến.

Struct và class

structclass
Loạikiểu giá trịkiểu tham chiếu
Phép gán và tham sốsao chép dữ liệusao chép tham chiếu
Có thể là nullkhông (T? thì có)có
Giá trị mặc địnhmọi trường bằng 0null
Kế thừakhông; có thể cài đặt interfacemột lớp cơ sở, các interface
==không định nghĩa trừ khi bạn nạp chồngso sánh tham chiếu trừ khi được nạp chồng
Equals mặc địnhso sánh các trườngso sánh tham chiếu
Cấp phátnội tuyến (stack hoặc đối tượng chứa)heap, được thu gom rác
Phù hợp vớigiá trị nhỏ, bất biếnthực thể, trạng thái lớn hoặc dùng chung

Khi nào nên dùng struct

Chọn struct khi tất cả các điều sau đều đúng: kiểu là một giá trị logic duy nhất (một tọa độ, một khoản tiền, một màu, một khoảng ngày), nó nhỏ (hướng dẫn của Microsoft là khoảng 16 byte, tầm bốn int), nó bất biến, và bạn không cần kế thừa. Struct có lợi khi bạn tạo rất nhiều, ví dụ hàng triệu điểm trong một mảng, vì chúng tránh được một lần cấp phát trên heap và một mục cho bộ thu gom rác cho mỗi phần tử.

Chọn class cho mọi thứ có danh tính (một khách hàng, một đơn hàng), mọi thứ lớn, mọi thứ bị sửa từ nhiều nơi, và bất cứ khi nào bạn không chắc. Một struct lớn và có thể thay đổi mang lại cả chi phí sao chép lẫn sự rối rắm khi sửa các bản sao.

record struct (C# 10)

C# 10 bổ sung record struct, tự sinh so sánh bằng theo giá trị, ==, ToString và hỗ trợ with cho một struct chỉ trong một dòng:

public readonly record struct Coordinate(double Lat, double Lng);

var home = new Coordinate(38.72, -9.14);
var same = new Coordinate(38.72, -9.14);
Console.WriteLine(home == same);            // True
Console.WriteLine(home);                    // Coordinate { Lat = 38.72, Lng = -9.14 }
var north = home with { Lat = 38.80 };

Một record struct thông thường có các thuộc tính vị trí có thể thay đổi; readonly record struct biến chúng thành init-only, thường là điều bạn muốn. Trang về record trình bày phiên bản class và chỉ ra các thành viên mà trình biên dịch viết sẵn.

Lỗi thường gặp

  • Sửa struct thông qua một bản sao. list[0].X = 1 (CS1612), biến của foreach (CS1654), kết quả của getter thuộc tính. Hãy ghi bản sao đã sửa trở lại, hoặc làm struct bất biến.
  • Struct lớn. Mỗi phép gán và lời gọi đều sao chép toàn bộ. Khi vượt quá vài trường, class thường nhanh hơn.
  • Quên giá trị mặc định toàn số 0. Mảng và default(T) tạo giá trị struct mà không chạy hàm khởi tạo của bạn, nên các trường bạn kiểm tra ở đó vẫn có thể là 0 hoặc null.
  • So sánh bằng == khi chưa định nghĩa nó. Lỗi CS0019. Hãy cài đặt IEquatable<T> và các toán tử, hoặc dùng record struct.
  • Struct có thể thay đổi nằm sau interface. Boxing nghĩa là thay đổi xảy ra trên một bản sao mà bạn không nhìn thấy.

Câu hỏi thường gặp

Struct trong C# là gì?

Struct là một kiểu giá trị do bạn tự định nghĩa: struct Point { public int X; public int Y; }. Một biến kiểu struct chứa chính dữ liệu chứ không chứa tham chiếu tới một đối tượng, nên khi gán hay truyền vào phương thức thì toàn bộ giá trị được sao chép. int, double, DateTime và Guid đều là struct.

Struct và class trong C# khác nhau thế nào?

Class là kiểu tham chiếu: các biến dùng chung một đối tượng, và được phép là null. Struct là kiểu giá trị: mỗi biến có bản sao riêng, không thể là null (trừ khi bạn dùng Point?), không kế thừa và không bị kế thừa, và giá trị mặc định là mọi trường đều bằng 0. Struct hợp với các giá trị nhỏ, bất biến; class hợp với các thực thể có danh tính và hành vi.

Khi nào nên dùng struct thay cho class trong C#?

Khi kiểu dữ liệu đại diện cho một giá trị nhỏ duy nhất (một tọa độ, một khoản tiền, một khoảng ngày), tốt nhất là bất biến, được so sánh theo nội dung, và được tạo với số lượng lớn đến mức việc tránh cấp phát trên heap là quan trọng. Hướng dẫn của Microsoft thêm điều kiện kích thước khoảng 16 byte trở xuống. Nếu kiểu có danh tính, có nhiều trường, hoặc bị thay đổi qua tham chiếu, hãy dùng class.

Vì sao tôi gặp lỗi "Cannot modify the return value because it is not a variable"?

Đó là lỗi CS1612, thường do list[0].X = 5 trên một List<Point> chứa struct. Indexer trả về một bản sao của struct, nên thay đổi bản sao sẽ bị mất, và trình biên dịch từ chối. Hãy chép phần tử vào một biến, sửa nó rồi gán lại: var p = list[0]; p.X = 5; list[0] = p;. Mảng không gặp vấn đề này vì array[0] trỏ tới chính phần tử đó.

readonly struct trong C# là gì?

readonly struct (C# 7.2) khai báo rằng không thành viên nào của struct thay đổi trạng thái của nó: mọi trường phải là readonly và auto-property chỉ có get. Trình biên dịch bắt buộc điều này, và nhờ đó tránh được các bản sao phòng thủ khi struct được truyền bằng in hoặc lưu trong một trường readonly. Phần lớn struct nên là readonly.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu