Bài viết này được dịch từ blog của tác giả Mads Torgersen (C# Lead Designer, .NET Team) – Bạn có thể xem bản gốc lại ở đây.
Trong bài viết này, chúng ta sẽ xem xét các tính năng mới trong C# 9 và làm sao có thể được sử dụng để cải thiện code của bạn.
Với mọi phiên bản C# mới, chúng ta cố gắng mang lại sự rõ ràng và đơn giản hơn trong các tình huống lập trình phổ biến và C# 9.0 cũng không phải là ngoại lệ. Một trọng tâm cụ thể lần này là hỗ trợ biểu diễn ngắn gọn và bất biến của các hình dạng dữ liệu.
Đối với OOP, việc C# cho phép khởi tạo đối tượng thực sự khá tuyệt vời vì tính tiện lợi. Nó cung cấp một loại định dạng dễ đọc để tạo một đối tượng, và nó đặc biệt hữu ích khi tạo những đối tượng lồng nhau trong đó toàn bộ Cây Đối Tượng được tạo chỉ trong một lần. Đây là một VD đơn giản:
new Person { FirstName = "Scott", LastName = "Hunter" }
Phương thức khởi tạo đối tượng cũng được loại bỏ nhờ vào đợt phát hành này – tất cả những gì ta phải làm là viết cho một số thuộc tính!
public class Person { public string FirstName { get; set; } public string LastName { get; set; } }
Một hạn chế hiện tại là các thuộc tính chỉ có thể thay đổi trong các phương thức khởi tạo nếu dùng “get” hoặc có thể thay đổi bất kỳ phương thức nào nếu dùng “set”
Thuộc tính Init-only khắc phục điều này – Bằng cách dùng từ khóa “init” – một biến thể của “set” bắt buộc lớp con chỉ có thể gọi trong quá trình khởi tạo đối tượng:
public class Person { public string FirstName { get; init; } public string LastName { get; init; } }
Với khai báo này, mã khách hàng ở trên vẫn hợp lệ, nhưng bất kỳ phép gán nào sau đó cho thuộc tính FirstName và LastName đều gây lỗi.
Bởi vì các trình truy cập init chỉ có thể được gọi trong hàm khởi tạo, nó được phép thay đổi các thuộc tính Read-Only của lớp bao quanh, giống như bạn có thể làm trong một phương thức khởi tạo.
public string FirstName { get => firstName; init => firstName = (value ?? throw new ArgumentNullException(nameof(FirstName))); } public string LastName { get => lastName; init => lastName = (value ?? throw new ArgumentNullException(nameof(LastName))); }
Thuộc tính Init-only khá tiện dụng và mang nhiều lợi ích khi bạn muốn các thuộc tính riêng lẻ trở nên bất biến. Nếu bạn muốn toàn bộ đối tượng trở nên bất biến và hoạt động như một tập giá trị (value), hãy xem xét khai báo đối tượng dưới dạng record thay vì class như trước đây:
public data class Person { public string FirstName { get; init; } public string LastName { get; init; } }
hoặc
public record Person { public string FirstName { get; init; } public string LastName { get; init; } }
Từ khóa data trước khai báo class cho biết nó là một record. Điều này làm cho nó mang thêm một số hành vi giống như giá trị (value) mà chúng ta sẽ tìm hiểu ở đây. Nói chung, các record được coi là “giá trị (value)” – dữ liệu! – mà không phải là đối tượng. Nó không có nghĩa là bắt buộc đóng gói trạng thái có thể thay đổi. Thay vào đó, bạn có thể thể hiện sự thay đổi dần theo thời gian bằng cách tạo những record mới đại diện cho trạng thái mới. Điều này nghĩa là, record không được xác định bởi ID, mà bởi những content của nó.
Khi phải làm việc với dữ liệu bất biến, một pattern phổ biến là tạo các giá trị mới từ các giá trị hiện có để thể hiện một trạng thái mới. Ví dụ: nếu Person của chúng ta thay đổi LastName của họ, chúng ta sẽ thể hiện nó như một đối tượng mới là bản sao của cái cũ, ngoại trừ LastName khác. Kỹ thuật này thường được gọi là đột biến không phá hủy. Thay vì đại diện cho người theo thời gian, record đại diện cho trạng thái của người đó tại một thời điểm nhất định.
Để trợ giúp với kiểu lập trình này, các record cho phép một kiểu biểu thức mới; biểu thức “with”:
var otherPerson = person with { LastName = "Hanselman" };
Biểu thức with sử dụng cú pháp của hàm khởi tạo đối tượng để nêu rõ những sự khác biệt trong đối tượng mới với đối tượng cũ. Bạn có thể chỉ định nhiều thuộc tính ở đây.
Record ngầm định nghĩa một “phương thức tạo sao chép” protected – một phương thức khởi tạo lấy một đối tượng record hiện có và sao chép từng thuộc tính của nó sang đối tượng mới:
protected Person(Person original) {/ * sao chép tất cả các thuộc tính * /} // được tạo
Biểu thức with giúp tạo một bản sao, sau đó áp dụng hàm khởi tạo đối tượng ở trên để thay đổi các thuộc tính cho phù hợp.
Nếu không thích hành vi mặc định của hàm tạo bản sao đã tạo, thay vào đó, bạn có thể xác định hành vi mình muốn và khi đó, một lựa chọn cho bạn là biểu thức with.
Tất cả các object đều kế thừa một phương thức ảo Equals(object) từ lớp object. Hàm này được sử dụng làm cơ sở cho phương thức tĩnh Object.Equals(object, object) khi cả hai tham số đều khác rỗng.
Struct được thay thế để tính “bằng nhau dựa trên giá trị (value)”, so sánh từng thuộc tính của cấu trúc bằng cách gọi Equals một cách đệ quy. Và với Record cũng làm như vậy.
Điều này nghĩa là hai đối tượng record có thể bằng nhau nhưng lại không phải là cùng một đối tượng. Ví dụ: nếu ta sửa lại lần nữa LastName của Person đã bị sửa đổi trước đó:
var originalPerson = otherPerson with { LastName = "Hunter" };
Bây giờ ta sẽ có ReferenceEquals(person, originalPerson) = false (nó không phải là cùng một đối tượng) nhưng Equals(person, originalPerson) = true (chúng có cùng giá trị).
Nếu bạn không thích hành vi so sánh theo-từng-thuộc-tính của Equals, bạn có thể viết code của riêng mình. Bạn chỉ cần lưu ý rằng bạn hiểu cách hoạt động của Equals dựa trên giá trị trong các record, đặc biệt là khi có lớp kế thừa.
Bên cạnh Equals dựa theo giá trị, GetHashCode() cũng dựa trên giá trị.
Các record với mục đích bất biến, với các thuộc tính init phải có khả năng được sửa đổi thông qua biểu thức with. Để tối ưu hóa cho trường hợp phổ biến đó, các record thay đổi giá trị mặc định của khai báo thành viên đơn giản của string FirstName có nghĩa là gì. Thay vì mặc định là thuộc tính private, như trong các khai báo lớp và cấu trúc khác; trong các record, mặc định sẽ là viết tắt của một thuộc tính only-init tự động công khai!
public data class Person { string FirstName; string LastName; }
Có nghĩa là giống hệt như cái mà chúng ta đã có trước đây:
public data class Person { public string FirstName { get; init; } public string LastName { get; init; } }
Chúng tôi nghĩ rằng điều này giúp cho việc kê khai Record đẹp và rõ ràng. Nếu bạn thực sự muốn một trường riêng tư, bạn có thể thêm công cụ sửa đổi riêng một cách rõ ràng:
private string firstName;
Đôi khi sẽ hữu ích khi có một cách tiếp cận gần hơn hơn đối với record, trong đó nội dung của nó được đưa ra thông qua các param của hàm khởi tạo và có thể được trích xuất bằng giải cấu trúc vị trí.
Hoàn toàn có thể chỉ định hàm tạo và code của riêng bạn trong một record:
public data class Person { string FirstName; string LastName; public Person(string firstName, string lastName) => (FirstName, LastName) = (firstName, lastName); public void Deconstruct(out string firstName, out string lastName) => (firstName, lastName) = (FirstName, LastName); }
Nhưng có một cú pháp ngắn hơn nhiều để diễn đạt chính xác cùng một thứ (cách viết hoa mô-đun của tên tham số):
public data class Person(string FirstName, string LastName);
Điều này khai báo các thuộc tính tự động chỉ init công khai và phương thức khởi tạo và phương thức code, để bạn có thể viết:
var person = new Person("Scott", "Hunter"); // positional construction var (f, l) = person; // positional deconstruction
Nếu bạn không thích thuộc tính tự động được tạo, bạn có thể xác định thuộc tính cùng tên của riêng mình thay vào đó, hàm khởi tạo và mã code được tạo sẽ chỉ sử dụng thuộc tính đó.
Ngữ nghĩa dựa trên giá trị của một record không phù hợp với trạng thái có tính bất biến. Hãy tưởng tượng đặt một đối tượng record vào từ điển. Việc tìm lại nó dựa vào Equals và (đôi khi dùng) GetHashCode. Nhưng nếu record thay đổi trạng thái của nó, nó cũng sẽ thay đổi giá trị của nó! Chúng ta có thể không thể tìm thấy nó nữa! Trong khi code cho bảng Hash, thậm chí có thể làm hư cấu trúc dữ liệu, vì vị trí được dựa trên mã hash tại thời điểm “xuất hiện”!
Có thể có một số cách sử dụng nâng cao hợp lệ của trạng thái có thể thay đổi bên trong các record, đặc biệt là cho bộ nhớ đệm. Nhưng công việc liên quan đến việc ghi đè các hành vi mặc định để bỏ qua trạng thái đó sẽ rất thủ công, và mất thời gian đáng kể.
Thách thức cho việc tính Equal dựa trên giá trị và đột biến không phân hủy là khó khăn khi kết hợp với yếu tố kế thừa. Hãy thêm một record Student kế thừa từ ví dụ hiện ta của chúng ta:
public data class Person { string FirstName; string LastName; } public data class Student : Person { int ID; }
Và hãy bắt đầu ví dụ với biểu thức của chúng ta bằng cách thực sự tạo một Student, nhưng lưu trữ nó trong một biến Person:
Person person = new Student { FirstName = "Scott", LastName = "Hunter", ID = GetNewId() }; otherPerson = person with { LastName = "Hanselman" };
Lúc này, biểu thức with trên dòng cuối cùng, trình biên dịch không biết rằng người đó thực sự chứa một Student. Tuy nhiên, một person mới sẽ không phải là một bản sao chính xác nếu đó không thực sự là một đối tượng Student, hoàn chỉnh với cùng một ID với người đầu tiên được sao chép.
C# giải quyết điều này bằng cách: Các record có một phương thức ảo ẩn được giao phó nhiệm vụ “nhân bản” toàn bộ đối tượng. Mọi kiểu record kết thừa sẽ ghi đè phương thức này để gọi hàm tạo bản sao của kiểu nào đó và hàm tạo sao chép của chuỗi record kế thừa tới hàm tạo bản sao của record cơ sở. Biểu thức with chỉ đơn giản gọi phương thức ẩn “tạo bản sao” và áp dụng trình khởi tạo đối tượng để cho kết quả.
Tương tự như hỗ trợ với biểu thức, tính Equals dựa trên giá trị cũng phải là phương thức ảo, theo nghĩa là Student cần so sánh tất cả các thuộc tính của nó, ngay cả khi kiểu tĩnh đã biết khi so sánh là kiểu cơ sở như Person. Điều đó có thể dễ dàng đạt được bằng cách ghi đè phương thức ảo Equals.
Tuy nhiên, có một thách thức nữa đối với Equals: Điều gì sẽ xảy ra nếu bạn so sánh hai loại Person khác nhau? Chúng ta thực sự không thể để một trong hai quyết định áp dụng đẳng thức nào: Equals phải mang tính đối xứng, vì vậy kết quả phải giống nhau bất kể đối tượng nào trong hai đối tượng đến trước.
Một ví dụ để minh họa vấn đề này:
Person person1 = new Person { FirstName = "Scott", LastName = "Hunter" }; Person person2 = new Student { FirstName = "Scott", LastName = "Hunter", ID = GetNewId() };
Hai đối tượng có bằng nhau không? person1 có thể nghĩ như vậy, vì person2 có tất cả những điều đúng của Person, nhưng person2 sẽ cầu xin sự khác biệt! Chúng ta cần đảm bảo rằng cả hai đều đồng ý rằng chúng là các đối tượng khác nhau.
Một lần nữa, C# sẽ tự động giải quyết việc này cho bạn. Cách thực hiện là các record có một thuộc tính ảo protected có tên là EqualityContract. Mọi record dẫn xuất sẽ ghi đè nó và để so sánh bằng nhau, hai đối tượng phải có cùng EqualityContract.
Viết một chương trình đơn giản trong C# yêu cầu một lượng lớn mã soạn sẵn:
using System; class Program { static void Main() { Console.WriteLine("Hello World!"); } }
Điều này không chỉ gây “bối rối” cho những người mới bắt đầu học ngôn ngữ mà còn làm lộn xộn code cộng thêm qui tắc thụt lề.
Trong C# 9.0, bạn chỉ có thể chọn viết chương trình của mình ở cấp cao nhất để thay thế:
using System; Console.WriteLine("Hello World!");
Mọi statement đều được phép. Chương trình phải xảy ra sau từ khóa using và trước bất kỳ khai báo kiểu hoặc namespace tên nào trong tệp và bạn chỉ có thể thực hiện việc này trong một tệp, cũng như bạn chỉ có thể có một phương thức chính hiện nay.
Nếu bạn muốn trả lại mã trạng thái, bạn có thể làm điều đó. Nếu bạn muốn await bạn có thể làm điều đó. Và nếu bạn muốn truy cập các đối số dòng lệnh, args có sẵn như một tham số “magic”.
Function cục bộ là một dạng câu lệnh và cũng được phép sử dụng trong chương trình cấp cao nhất. Sẽ có lỗi khi gọi chúng từ bất kỳ đâu không thuộc code ở top-level này.
Một số kiểu mẫu mới đã được thêm vào trong C # 9.0. Hãy xem chúng trong ngữ cảnh của đoạn mã này từ hướng dẫn đối sánh mẫu:
public static decimal CalculateToll(object vehicle) => vehicle switch { … DeliveryTruck t when t.GrossWeightClass > 5000 => 10.00m + 5.00m, DeliveryTruck t when t.GrossWeightClass < 3000 => 10.00m - 2.00m, DeliveryTruck _ => 10.00m, _ => throw new ArgumentException("Not a known vehicle type", nameof(vehicle)) };
Hiện tại, một pattern cho type cần khai báo ID khi type đó khớp – ngay cả khi số ID đó là một discard _, như trong DeliveryTruck ở trên. Nhưng bây giờ bạn chỉ cần viết:
DeliveryTruck => 10.00m,
C# 9.0 giới thiệu các pattern tương ứng với các toán tử quan hệ <, <=, v.v… Vì vậy, bây giờ bạn có thể viết phần DeliveryTruck của mẫu trên dưới dạng một biểu thức switch lồng nhau:
DeliveryTruck t when t.GrossWeightClass switch { > 5000 => 10.00m + 5.00m, < 3000 => 10.00m - 2.00m, _ => 10.00m, }
Ở đây > 5000 và <3000 là các pattern quan hệ.
Cuối cùng, bạn có thể kết hợp các pattern với các toán tử logic và hoặc không, được viết dưới dạng các từ để tránh nhầm lẫn với các toán tử được sử dụng trong biểu thức. Ví dụ: các trường hợp của công tắc lồng nhau ở trên có thể được đặt theo thứ tự tăng dần như sau:
DeliveryTruck t when t.GrossWeightClass switch { < 3000 => 10.00m - 2.00m, >= 3000 and <= 5000 => 10.00m, > 5000 => 10.00m + 5.00m, Add Code vao Day },
Trường hợp giữa ở đó sử dụng và kết hợp hai mẫu quan hệ và tạo thành một mẫu biểu thị một khoảng.
Một cách sử dụng phổ biến của mẫu not sẽ là áp dụng nó cho mẫu hằng null, cũng như không phải null. Ví dụ: chúng ta có thể chia nhỏ việc xử lý các trường hợp không xác định tùy thuộc vào việc chúng có rỗng hay không:
not null => throw new ArgumentException($"Not a known vehicle type: {vehicle}", nameof(vehicle)), null => throw new ArgumentNullException(nameof(vehicle))
Thường sẽ không thuận tiện trong điều kiện if chứa biểu thức is, thay vì dùng 2 dấu ngoặc đơn khó rườm rà nhừ vầy:
if (!(e is Customer)) { … }
Bạn chỉ cần:
if (e is not Customer) { … }
“Target typing” là một thuật ngữ đội ngũ .NET sử dụng khi một biểu thức nhận được về kiểu của nó từ context mà nó đang được sử dụng. Ví dụ: các biểu thức null và lambda luôn là target-type.
Trong C# 9.0, một số biểu thức mà trước đây không có support target-typing giờ có thể được hiểu bằng context của nó.
Các biểu thức new trong C# luôn yêu cầu một kiểu được chỉ định (ngoại trừ các biểu thức mảng được nhập ngầm). Bây giờ bạn có thể loại bỏ loại nếu có một type rõ ràng mà các biểu thức đang được gán.
Point p = new (3, 5);
Đôi khi biểu thức điều kiện ?? và ?: không có cùng type rõ ràng giữa các nhánh. Những trường hợp như vậy trước đây sẽ không thể thực hiện, nhưng C# 9.0 sẽ cho phép chúng nếu có một loại đích mà cả hai nhánh đều chuyển đổi thành:
Person person = student ?? customer; // Shared base type int? result = b ? 0 : null; // nullable value type
Đôi khi hữu ích khi diễn đạt rằng phương thức ghi đè trong lớp dẫn xuất có kiểu trả về cụ thể hơn so với khai báo trong kiểu cơ sở. C# 9.0 cho phép:
abstract class Animal { public abstract Food GetFood(); … } class Tiger : Animal { public override Meat GetFood() => …; }
» Tin mới nhất:
» Các tin khác: