Menu

Thuộc tính trong C#: get, set, auto-property, init và required

Cách thuộc tính trong C# hoạt động: get và set bao quanh một trường lưu trữ, auto-property, private set và thuộc tính chỉ đọc, thuộc tính tính toán, kiểm tra dữ liệu trong setter, cùng các từ khóa init và required từ C# 9 và 11.

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.

Thuộc tính (property) trông giống một trường đối với code dùng nó (order.Total, order.Total = 5) nhưng thực chất là một cặp phương thức: accessor get trả về một giá trị và accessor set nhận một giá trị. Lớp trung gian đó cho phép một lớp kiểm tra giá trị, tính toán chúng hoặc từ chối việc ghi, mà không thay đổi cách nơi gọi sử dụng.

Trường và thuộc tính

Một trường public trao quyền truy cập trực tiếp vào vùng lưu trữ của đối tượng. Ai cũng có thể đặt bất cứ thứ gì vào đó:

public class Account
{
    public decimal Balance;   // any code can write -500 here
}

Thuộc tính đặt code vào giữa nơi gọi và vùng lưu trữ. Đây là dạng đầy đủ, viết tay: một trường lưu trữ (backing field) private cùng một thuộc tính có cả hai accessor. Bên trong set, từ khóa value là giá trị đang được gán.

Đầu ra:

250
Rejected a negative balance
250

Ngoài việc kiểm tra dữ liệu, thuộc tính còn quan trọng vì phần lớn hệ sinh thái .NET làm việc với thuộc tính và bỏ qua trường: interface có thể khai báo thuộc tính nhưng không khai báo được trường instance, data binding trong WPF và MAUI gắn với thuộc tính, và System.Text.Json mặc định serialize các thuộc tính public và bỏ qua trường.

Auto-property

Khi các accessor chỉ đọc và ghi trường, hãy để trình biên dịch viết chúng. { get; set; } là một thuộc tính tự động (auto-implemented property): trình biên dịch tự sinh trường lưu trữ ẩn cho bạn.

Đầu ra:

Notebook: 9.99, 10 left

Trông giống một trường, vậy sao phải bận tâm? Vì việc đổi một trường public thành thuộc tính về sau là thay đổi phá vỡ tương thích với code đã biên dịch đang dùng nó (và với các đối số ref và out), còn việc đổi một auto-property thành thuộc tính đầy đủ có kiểm tra dữ liệu thì nơi gọi hoàn toàn không thấy. Bắt đầu với { get; set; } giữ sẵn lựa chọn đó mà không tốn gì.

private set và thuộc tính chỉ đọc

Phần lớn trạng thái nên cho mọi người đọc nhưng chỉ chính đối tượng được thay đổi. Hãy cho setter một phạm vi truy cập hẹp hơn:

Đầu ra:

Ines: 2 items, total 15.75

Ở đây có ba kiểu chỉ đọc:

  • { get; private set; }: lớp có thể thay đổi nó bất cứ lúc nào, code bên ngoài thì không.
  • { get; } (auto-property chỉ có get, C# 6): chỉ có thể gán trong hàm khởi tạo hoặc initializer. Sau khi tạo xong không ai thay đổi được, kể cả chính lớp đó. Đây là cách tạo một thuộc tính bất biến.
  • => biểu thức (thuộc tính tính toán): không có vùng lưu trữ nào cả. Biểu thức chạy mỗi lần đọc, nên Total luôn khớp với danh sách.

Thuộc tính tính toán nên nhẹ và không có tác dụng phụ, vì nơi gọi đọc thuộc tính một cách thoải mái, trong vòng lặp lẫn trong trình gỡ lỗi. Nếu việc lấy giá trị tốn công thật sự (một truy vấn cơ sở dữ liệu, một phép tính lớn), hãy biến nó thành một phương thức như CalculateTotal() để chi phí lộ rõ.

Accessor dạng biểu thức

Thuộc tính đầy đủ với các accessor một dòng có thể dùng => cho từng accessor:

private string title;

public string Title
{
    get => title;
    set => title = value?.Trim() ?? "";
}

public decimal Total => SumPrices(); là cách viết tắt cho một thuộc tính chỉ có getter; get => ... là cùng ý tưởng đó bên trong một thuộc tính có cả setter.

Logic trong getter và setter: thông báo và giá trị lười

Vì accessor là phương thức, chúng có thể làm nhiều hơn việc lưu một giá trị. Hai mẫu thường gặp: phát thông báo thay đổi từ setter, và tính một giá trị ở lần đọc đầu tiên rồi lưu đệm lại.

Đầu ra:

(building summary)
Theme: light
Theme: light
Theme changed
(building summary)
Theme: dark

Changed là một sự kiện, và mẫu setter này chính là hình dạng của các lớp hiện thực INotifyPropertyChanged trong WPF và MAUI. nameof(Theme) giữ chuỗi tên luôn khớp nếu thuộc tính bị đổi tên.

Lỗi đệ quy vô hạn

Bug phổ biến nhất với thuộc tính là một setter gán vào chính thuộc tính thay vì vào trường lưu trữ:

public string Name
{
    get { return Name; }        // calls get again, forever
    set { Name = value; }       // calls set again, forever
}

Mỗi accessor tự gọi lại chính nó, và chương trình sập với StackOverflowException, một ngoại lệ không thể bắt được. Cách sửa là đọc và ghi một trường riêng (name, viết thường), hoặc dùng auto-property. C# 14 bổ sung từ khóa field cho đúng trường hợp này: bên trong một accessor, field trỏ tới trường lưu trữ do trình biên dịch sinh ra, nên set => field = value.Trim(); hoạt động mà không cần khai báo trường.

Accessor init (C# 9)

Thuộc tính chỉ có get buộc bạn truyền mọi giá trị qua hàm khởi tạo. C# 9 bổ sung init, một setter chỉ được phép dùng trong lúc tạo đối tượng: trong hàm khởi tạo hoặc trong object initializer.

public class Product
{
    public string Sku { get; init; }
    public decimal Price { get; init; }
}

var p = new Product { Sku = "MUG-01", Price = 8.50m };   // fine: during creation
p.Price = 4m;   // error CS8852: Init-only property or indexer 'Product.Price' can only be assigned
                // in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor

Kết quả là một đối tượng bất biến có cú pháp initializer, đúng thứ mà record class dùng cho các thuộc tính vị trí của chúng. Để "thay đổi" một đối tượng như vậy, hãy tạo một đối tượng mới; record biến việc đó thành một dòng code với with.

Trên C# 7, cách gần tương đương nhất là thuộc tính chỉ có get cộng với một tham số của hàm khởi tạo:

public class Product
{
    public string Sku { get; }
    public decimal Price { get; }
    public Product(string sku, decimal price) { Sku = sku; Price = price; }
}

Thành viên required (C# 11)

Object initializer không thể bắt nơi gọi gán bất cứ thứ gì: new Product { } vẫn biên dịch được dù Sku không bao giờ được để trống. C# 11 bổ sung từ khóa required:

public class User
{
    public required string Email { get; init; }
    public string DisplayName { get; init; } = "";
}

var ok = new User { Email = "ana@example.com" };
var bad = new User { DisplayName = "Ana" };   // error CS9035: Required member 'User.Email'
                                              // must be set in the object initializer or attribute constructor.

required dùng được với set và init. Một hàm khởi tạo tự gán các thành viên required có thể khai báo điều đó bằng thuộc tính [SetsRequiredMembers], để nơi gọi dùng hàm khởi tạo đó không bị yêu cầu gán lại chúng.

Lỗi thường gặp

  • Đệ quy trong accessor. set { Name = value; } tự gọi lại chính nó. Hãy dùng trường lưu trữ hoặc auto-property.
  • Getter tốn kém hoặc có tác dụng phụ. Người đọc code kỳ vọng việc đọc thuộc tính nhanh và có thể lặp lại an toàn. Hãy dùng phương thức cho những việc nặng.
  • Trường public "tạm thời". Đổi trường thành thuộc tính về sau sẽ phá vỡ tương thích nhị phân, và các bộ serialize mặc định bỏ qua trường. Hãy bắt đầu với auto-property.
  • Setter public cho mọi thứ. { get; set; } trên số dư hay trạng thái mở đường cho trạng thái không hợp lệ. Hãy dùng private set, init hoặc chỉ get, và thay đổi trạng thái qua phương thức.
  • Trả về collection có thể thay đổi từ thuộc tính chỉ có get. public List<string> Tags { get; } ngăn nơi gọi thay cả danh sách, chứ không ngăn họ thêm phần tử vào. Hãy trả về IReadOnlyList<string> khi collection không được phép thay đổi từ bên ngoài.

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

{ get; set; } trong C# nghĩa là gì?

Nó khai báo một thuộc tính tự động (auto-property): trình biên dịch tạo một trường private ẩn, một accessor get trả về trường đó và một accessor set gán giá trị cho nó. public string Name { get; set; } hoạt động như một trường public đối với nơi gọi, nhưng thực chất là một cặp phương thức, nên về sau bạn có thể thêm kiểm tra dữ liệu hoặc đổi thành private set mà không phải sửa code đang dùng nó.

Trường (field) và thuộc tính (property) trong C# khác nhau thế nào?

Trường là một biến được lưu trong đối tượng. Thuộc tính là một cặp phương thức (get và set) trông giống một trường khi nhìn từ bên ngoài. Thuộc tính cho phép lớp kiểm tra giá trị, tính toán kết quả hoặc hạn chế việc ghi, và chúng là thứ mà data binding, các bộ serialize và interface làm việc cùng. Dữ liệu công khai nên được đưa ra dưới dạng thuộc tính; trường thường để private.

private set trong C# là gì?

public int Stock { get; private set; } cho phép mọi đoạn code đọc thuộc tính nhưng chỉ code bên trong lớp mới thay đổi được nó. Đây là cách một lớp công khai trạng thái do nó sở hữu, như số dư hay một bộ đếm, trong khi mọi thay đổi đều phải đi qua các phương thức áp dụng quy tắc.

init trong C# làm gì?

init (C# 9) là một setter chỉ hoạt động trong lúc đối tượng đang được tạo: trong hàm khởi tạo hoặc trong object initializer. public string Sku { get; init; } cho phép new Product { Sku = "A-1" } nhưng từ chối product.Sku = "B-2" về sau với lỗi CS8852. Nó mang cú pháp initializer đến cho các đối tượng bất biến.

Thuộc tính required trong C# là gì?

Một thuộc tính được đánh dấu required (C# 11) phải được gán trong mọi object initializer tạo ra kiểu đó; bỏ sót nó là lỗi biên dịch (CS9035). Nó kết hợp tốt với init: public required string Email { get; init; } phải được cung cấp lúc tạo và không thể thay đổi về sau.

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