Các tính năng mới của C# 8.0

C# 8.0 được Microsoft phát hành vào tháng 9 - 2019 cùng với .NET Framework 4.8 và Visual Studio 2019.

C# 8.0 là bản phát hành C# lớn đầu tiên tập trung vào .NET Core. Một số tính năng dựa trên các khả năng CLR mới, một số tính năng khác chỉ được thêm vào .NET Core. C# 8.0 thêm các tính năng và cải tiến sau vào ngôn ngữ C#:

  • Thành viên readonly của struct.
  • Phương thức interface mặc định.
  • Biểu thức mẫu ở mọi nơi.
  • Khai báo using.
  • Hàm cục bộ static.
  • Xử lý ref struct.
  • Kiểu tham chiếu nullable.
  • Danh sách không đồng bộ.
  • Xử lý không đồng bộ.

C# 8.0 được hỗ trợ trên .NET Core 3.x.NET Standard 2.1.

Thành viên readonly của struct

Bạn có thể sử dụng từ khóa readonly cho các thành viên của một struct - điều này chỉ ra rằng một thành viên không thể thay đổi trạng thái. Việc này chi tiết hơn so với việc áp dụng từ khóa readonly khi một khai báo struct. Hãy xem struct sau:

public struct Point
{
    public double X { get; set; }
    public double Y { get; set; }
    public double Distance => Math.Sqrt(X * X + Y * Y);

    public override string ToString() =>
        $"({X}, {Y}) is {Distance} from the origin";
}

Như bạn có thể thấy ở ví dụ trên, phương thức ToString() không có bất kỳ dòng code nào làm thay đổi các trạng thái của struct. Do đó bạn có thể sử dụng từ khóa readonly khi ghi đè phương thức ToString() như sau:

public readonly override string ToString() =>
    $"({X}, {Y}) is {Distance} from the origin";

Thay đổi ở trên sẽ tạo cảnh báo trình biên dịch, vì phương thức ToString truy cập thuộc tính Distance không được đánh dấu là readonly:

warning CS8656: Call to non-readonly member 'Point.Distance.get' from a 'readonly' member results in an implicit copy of 'this'

Trình biên dịch cảnh báo bạn khi nó cần tạo trường sao lưu cho thuộc tính chỉ đọc. Thuộc tính Distance không thay đổi trạng thái, vì vậy bạn có thể khắc phục cảnh báo này bằng cách thêm từ khóa readonly như sau:

public readonly double Distance => Math.Sqrt(X * X + Y * Y);

Lưu ý rằng từ khóa readonly là cần thiết trên thuộc tính chỉ đọc (chỉ có bộ truy cập get). Trình biên dịch không cho rằng bộ truy cập get không làm thay đổi trạng thái do đó bạn phải khai báo readonly rõ ràng.

Tuy nhiên các thuộc tính được triển khai tự động (auto-implemented property) là một ngoại lệ; trình biên dịch sẽ coi tất cả các bộ truy cập get là chỉ đọc, vì vậy không cần khai báo readonly  cho các thuộc tính XY.

Trình biên dịch thực thi quy tắc để đảm bảo rằng các thành viên readonly  không được thay đổi trạng thái. Phương thức sau sẽ bị lỗi khi biên dịch trừ vì bạn đang cố gắng thay đổi giá trị của thuộc tính X, Y trong phương thức được khai báo readonly:

public readonly void Translate(int xOffset, int yOffset)
{
    X += xOffset;
    Y += yOffset;
}

Tính năng này cho phép bạn chỉ định mục đích thiết kế của mình để trình biên dịch có thể thực thi nó và thực hiện tối ưu hóa dựa trên ý định đó.

Phương thức interface mặc định

Bây giờ bạn có thể thêm thành viên vào interface và cung cấp triển khai cho các thành viên đó.

Tính năng ngôn ngữ này cho phép những người cung cấp API thêm phương thức vào interface trong các phiên bản mới mà không phá vỡ tính tương thích nguồn hoặc nhị phân với các triển khai hiện có của interface đó.

Các triển khai hiện có kế thừa việc thực hiện mặc định. Tính năng này cũng cho phép C# tương tác với các API cho Android hoặc Swift, nó hỗ trợ các tính năng tương tự.

Các phương thức interface mặc định cũng cho phép các tình huống tương tự như tính năng ngôn ngữ "traits".

public interface ICustomer
{
	IEnumerable<IOrder> PreviousOrders { get; }

	DateTime DateJoined { get; }
	DateTime? LastOrder { get; }
	string Name { get; }
	IDictionary<DateTime, string> Reminders { get; }
	
	/*
	// <SnippetLoyaltyDiscountVersionOne>
	// Version 1:
	public decimal ComputeLoyaltyDiscount()
	{
		DateTime TwoYearsAgo = DateTime.Now.AddYears(-2);
		if ((DateJoined < TwoYearsAgo) && (PreviousOrders.Count() > 10))
		{
			return 0.10m;
		}
		return 0;
	}
	// </SnippetLoyaltyDiscountVersionOne>
	*/

	/*
	// <SnippetLoyaltyDiscountVersionTwo>
	// Version 2:
	public static void SetLoyaltyThresholds(
		TimeSpan ago, 
		int minimumOrders = 10, 
		decimal percentageDiscount = 0.10m)
	{
		length = ago;
		orderCount = minimumOrders;
		discountPercent = percentageDiscount;
	}
	private static TimeSpan length = new TimeSpan(365 * 2, 0,0,0); // two years
	private static int orderCount = 10;
	private static decimal discountPercent = 0.10m;
	public decimal ComputeLoyaltyDiscount()
	{
		DateTime start = DateTime.Now - length;
		if ((DateJoined < start) && (PreviousOrders.Count() > orderCount))
		{
			return discountPercent;
		}
		return 0;
	}
	// </SnippetLoyaltyDiscountVersionTwo>
	*/
	
	// Version 3:
	public static void SetLoyaltyThresholds(TimeSpan ago, int minimumOrders, decimal percentageDiscount)
	{
		length = ago;
		orderCount = minimumOrders;
		discountPercent = percentageDiscount;
	}
	private static TimeSpan length = new TimeSpan(365 * 2, 0, 0, 0); // two years
	private static int orderCount = 10;
	private static decimal discountPercent = 0.10m;

	// <SnippetFinalVersion>
	public decimal ComputeLoyaltyDiscount() => DefaultLoyaltyDiscount(this);
	protected static decimal DefaultLoyaltyDiscount(ICustomer c)
	{
		DateTime start = DateTime.Now - length;

		if ((c.DateJoined < start) && (c.PreviousOrders.Count() > orderCount))
		{
			return discountPercent;
		}
		return 0;
	}
	// </SnippetFinalVersion>
}

Các phương thức interface mặc định ảnh hưởng đến nhiều kịch bản và các yếu tố ngôn ngữ.

Chúng tôi sẽ trình bày chi tiết về phương thức interface mặc định ở một bài viết khác.

Biểu thức mẫu ở mọi nơi

Khớp mẫu (pattern matching) được giới thiệu trong C# 7.0 cho phép bạn triển khai thêm các biểu thức cho biểu thức is và câu lệnh switch.

C# 8.0 mở rộng khả năng này để bạn có thể sử dụng nhiều biểu thức mẫu hơn ở nhiều vị trí hơn trong mã của mình.

Ngoài các mẫu mới ở các vị trí mới, C# 8.0 cung cấp thêm các mẫu đệ quy. Một mẫu đệ quy chỉ đơn giản là một biểu thức mẫu được áp dụng cho đầu ra của một biểu thức mẫu khác.

Biểu thức switch

Thông thường, một câu lệnh switch tạo ra một giá trị trong mỗi khối case của nó. Biểu thức switch cho phép bạn sử dụng cú pháp biểu thức ngắn gọn hơn. Ít lặp lại từ khóa casebreak, và ít dấu ngoặc nhọn hơn. Ví dụ, enum sau đây liệt kê các màu của cầu vồng:

public enum Rainbow
{
    Red,
    Orange,
    Yellow,
    Green,
    Blue,
    Indigo,
    Violet
}

Nếu ứng dụng của bạn định nghĩa một kiểu dữ liệu RGBColor được xây dựng từ các thành phần R, GB. Bạn có thể chuyển đổi một giá trị của Rainbow thành các giá trị RGB bằng cách sử dụng phương thức FromRainbow sau đây có chứa một biểu thức switch:

public static RGBColor FromRainbow(Rainbow colorBand) =>
    colorBand switch
    {
        Rainbow.Red    => new RGBColor(0xFF, 0x00, 0x00),
        Rainbow.Orange => new RGBColor(0xFF, 0x7F, 0x00),
        Rainbow.Yellow => new RGBColor(0xFF, 0xFF, 0x00),
        Rainbow.Green  => new RGBColor(0x00, 0xFF, 0x00),
        Rainbow.Blue   => new RGBColor(0x00, 0x00, 0xFF),
        Rainbow.Indigo => new RGBColor(0x4B, 0x00, 0x82),
        Rainbow.Violet => new RGBColor(0x94, 0x00, 0xD3),
        _              => throw new ArgumentException(message: "invalid enum value", paramName: nameof(colorBand)),
    };

Có một số cải tiến cú pháp ở đây:

  • Các biến đặt trước từ khóa switch. Thứ tự khác nhau giúp dễ dàng phân biệt biểu thức switch với câu lệnh switch.
  • Các phần tử case: được thay thế bằng =>. Nó ngắn gọn và trực quan hơn.
  • Các trường hợp default được thay thế bằng một loại bỏ _ (discards - cũng được giới thiệu trong C# 7.0).
  • Phần thân của biểu thức switch là biểu thức, không phải là các câu lệnh (statements).

Biểu thức switch trên tương đương với cách sử dụng câu lệnh switch cổ điển như dưới đây:

public static RGBColor FromRainbowClassic(Rainbow colorBand)
{
    switch (colorBand)
    {
        case Rainbow.Red:
            return new RGBColor(0xFF, 0x00, 0x00);
        case Rainbow.Orange:
            return new RGBColor(0xFF, 0x7F, 0x00);
        case Rainbow.Yellow:
            return new RGBColor(0xFF, 0xFF, 0x00);
        case Rainbow.Green:
            return new RGBColor(0x00, 0xFF, 0x00);
        case Rainbow.Blue:
            return new RGBColor(0x00, 0x00, 0xFF);
        case Rainbow.Indigo:
            return new RGBColor(0x4B, 0x00, 0x82);
        case Rainbow.Violet:
            return new RGBColor(0x94, 0x00, 0xD3);
        default:
            throw new ArgumentException(message: "invalid enum value", paramName: nameof(colorBand));
    };
}

Mẫu thuộc tính

Mẫu thuộc tính (property patterns) cho phép bạn khớp với các thuộc tính của đối tượng được kiểm tra.

Hãy xem xét một trang web thương mại điện tử phải tính thuế bán hàng dựa trên địa chỉ của người mua.

Tính toán đó không phải là trách nhiệm chính của lớp Address. Nó sẽ thay đổi theo thời gian, có thể thường xuyên hơn thay đổi định dạng địa chỉ. Số tiền thuế bán hàng phụ thuộc vào thuộc tính State của lớp Address.

Phương thức sau đây sử dụng mẫu thuộc tính và biểu thức switch để tính thuế bán hàng từ địa chỉ và giá:

public static decimal ComputeSalesTax(Address location, decimal salePrice) =>
    location switch
    {
        { State: "WA" } => salePrice * 0.06M,
        { State: "MN" } => salePrice * 0.75M,
        { State: "MI" } => salePrice * 0.05M,
        // other cases removed for brevity...
        _ => 0M
    };

Mẫu tuple

Một số giải thuật phụ thuộc vào nhiều đầu vào. Các mẫu tuple cho phép bạn chuyển đổi dựa trên nhiều giá trị được biểu thị dưới dạng một tuple. Đoạn mã sau đây minh họa biểu thức switch và các mẫu tuple cho trò chơi đá, giấy, kéo:

public static string RockPaperScissors(string first, string second)
    => (first, second) switch
    {
        ("rock", "paper") => "rock is covered by paper. Paper wins.",
        ("rock", "scissors") => "rock breaks scissors. Rock wins.",
        ("paper", "rock") => "paper covers rock. Paper wins.",
        ("paper", "scissors") => "paper is cut by scissors. Scissors wins.",
        ("scissors", "rock") => "scissors is broken by rock. Rock wins.",
        ("scissors", "paper") => "scissors cuts paper. Scissors wins.",
        (_, _) => "tie"
    };

Các thông điệp chỉ ra người chiến thắng. Trường hợp loại bỏ (discards) đại diện cho những trường hợp khác.

Mẫu vị trí

Một sốkiểu dữ liệu có một phương thức Deconstruct để tái cấu trúc các thuộc tính của nó thành các biến rời rạc.

Khi một phương thức Deconstruct có thể truy cập được, bạn có thể sử dụng các mẫu vị trí để kiểm tra các thuộc tính của đối tượng và sử dụng các thuộc tính đó cho một mẫu.

Hãy xem xét lớp Point sau bao gồm một phương thức Deconstruct để tạo các biến rời rạc cho XY:

public class Point
{
    public int X { get; }
    public int Y { get; }

    public Point(int x, int y) => (X, Y) = (x, y);

    public void Deconstruct(out int x, out int y) =>
        (x, y) = (X, Y);
}

Ngoài ra, hãy xem xét enum sau đại diện cho các vị trí khác nhau của góc phần tư (Quadrant):

public enum Quadrant
{
    Unknown,
    Origin,
    One,
    Two,
    Three,
    Four,
    OnBorder
}

Phương thức sau đây sử dụng mẫu vị trí để trích xuất các giá trị của xy. Sau đó, nó sử dụng một mệnh đề when để xác định Quadrant của điểm:

static Quadrant GetQuadrant(Point point) => point switch
{
    (0, 0) => Quadrant.Origin,
    var (x, y) when x > 0 && y > 0 => Quadrant.One,
    var (x, y) when x < 0 && y > 0 => Quadrant.Two,
    var (x, y) when x < 0 && y < 0 => Quadrant.Three,
    var (x, y) when x > 0 && y < 0 => Quadrant.Four,
    var (_, _) => Quadrant.OnBorder,
    _ => Quadrant.Unknown
};

Một biểu thức switch phải tạo ra một giá trị hoặc ném một ngoại lệ. Nếu không có trường hợp nào khớp, biểu thức switch sẽ ném ngoại lệ.

Trình biên dịch tạo cảnh báo cho bạn nếu bạn không bao gồm tất cả các trường hợp có thể có trong biểu thức switch của mình.

Khai báo using

Khai báo using là một khai báo biến bắt đầu bằng từ khóa using. Nó báo cho trình biên dịch rằng biến được khai báo nên được xử lý ở cuối phạm vi kèm theo. Ví dụ, hãy xem đoạn mã sau đây ghi file văn bản:

static int WriteLinesToFile(IEnumerable<string> lines)
{
    using var file = new System.IO.StreamWriter("WriteLines2.txt");
    // Notice how we declare skippedLines after the using statement.
    int skippedLines = 0;
    foreach (string line in lines)
    {
        if (!line.Contains("Second"))
        {
            file.WriteLine(line);
        }
        else
        {
            skippedLines++;
        }
    }
    // Notice how skippedLines is in scope here.
    return skippedLines;
    // file is disposed here
}

Trong ví dụ trên, biến file được disposed khi gặp dấu ngoặc đóng của phương thức. Đó là kết thúc của phạm vi của biến file được khai báo. Mã dưới đây tương tự ví dụ trên nhưng sử dụng câu lệnh using cổ điển :

static int WriteLinesToFile(IEnumerable<string> lines)
{
    // We must declare the variable outside of the using block
    // so that it is in scope to be returned.
    int skippedLines = 0;
    using (var file = new System.IO.StreamWriter("WriteLines2.txt"))
    {
        foreach (string line in lines)
        {
            if (!line.Contains("Second"))
            {
                file.WriteLine(line);
            }
            else
            {
                skippedLines++;
            }
        }
    } // file is disposed here
    return skippedLines;
}

Trong ví dụ trên, biến file được disposed khi gặp dấu ngoặc đóng của câu lệnh using.

Trong cả hai trường hợp, trình biên dịch đều tạo cuộc gọi đến Dispose() của biến file để hủy và giải phóng bộ nhớ. Trình biên dịch tạo ra lỗi nếu biểu thức trong câu lệnh using không được dùng lần nào.

Hàm cục bộ static

Hàm cục bộ đã được giới thiệu từ phiên bản C# 7.0.

Bây giờ bạn có thể thêm khai báo static vào các hàm cục bộ để đảm bảo rằng hàm cục bộ không tham chiếu tới bất kỳ biến nào trong phạm vi kèm theo. Nếu làm như vậy sẽ tạo ra lỗi CS8421, "A static local function can't contain a reference to <variable>."

Hãy xem ví dụ sau đây. Hàm cục bộ LocalFunction truy cập vào biến y, được khai báo trong phạm vi kèm theo (phương thức M). Do đó, LocalFunction không thể khai báo với từ khóa static được:

int M()
{
    int y;
    LocalFunction();
    return y;

    void LocalFunction() => y = 0;
}

Đoạn mã dưới đây khai báo một hàm cục bộ static. Nó có thể sử dụng khai báo static vì nó không truy cập bất kỳ biến nào trong phạm vi kèm theo (phương thức M):

int M()
{
    int y = 5;
    int x = 7;
    return Add(x, y);

    static int Add(int left, int right) => left + right;
}

Xử lý ref struct

Một struct được khai báo với từ khóa ref sẽ không thể thực hiện (implement) bất kỳ interface nào, do đó nó không thể thực hiện interface IDisposable.

Do đó để cho phép hủy và giải phóng bộ nhớ (disposed) cho một ref struct nó phải có một phương thức Dispose() có thể truy cập. Ví dụ sau minh họa điều này:

class Program
{
   static void Main(string[] args)
   {
      using (var book = new Book())
      {
         // ...
      }
    }
}

ref struct Book
{
   public void Dispose()
   {
   }
}

Tính năng này cũng có thể áp dụng cho readonly ref struct.

Kiểu tham chiếu nullable

Bạn không nghe lầm đâu, trước đây chỉ có kiểu giá trị nullable, nhưng từ bây giờ bạn sẽ có thêm kiểu tham chiếu nullable.

C# 8.0 giới thiệu các kiểu tham chiếu nullablecác kiểu tham chiếu không nullable cho phép bạn đưa ra các khai báo quan trọng về các thuộc tính cho các biến kiểu tham chiếu:

Một tham chiếu không được coi là null: Khi các biến không được coi là null, trình biên dịch sẽ thực thi các quy tắc đảm bảo an toàn để hủy đăng ký tới các biến này mà không cần kiểm tra trước rằng nó không null:

  • Biến phải được khởi tạo với giá trị khác null.
  • Biến không bao giờ có thể được gán giá trị null.

Một tham chiếu có thể là null: Khi các biến có thể là null, trình biên dịch sẽ thực thi các quy tắc khác nhau để đảm bảo rằng bạn đã kiểm tra chính xác tham chiếu null:

  • Biến chỉ có thể được hủy đăng ký khi trình biên dịch có thể đảm bảo rằng giá trị không null.
  • Các biến này có thể được khởi tạo với giá trị null mặc định và có thể được gán giá trị null trong mã.

Tính năng mới này cung cấp các lợi ích đáng kể trong việc xử lý các biến tham chiếu trong các phiên bản trước của C# trong đó mục đích thiết kế không thể được xác định lúc khai báo biến.

Trình biên dịch không cung cấp các xử lý an toàn trước các ngoại lệ tham chiếu null cho các kiểu tham chiếu:

  • Một tham chiếu có thể là null: Không có cảnh báo nào được đưa ra khi một kiểu tham chiếu được khởi tạo thành null hoặc sau đó nó được gán null.
  • Một tham chiếu được coi là không null: Trình biên dịch không đưa ra bất kỳ cảnh báo nào khi các kiểu tham chiếu bị hủy đăng ký. (Với các tham chiếu nullable, trình biên dịch sẽ đưa ra cảnh báo bất cứ khi nào bạn hủy đăng ký một biến có thể là null).

Với việc bổ sung các kiểu tham chiếu nullable, bạn có thể khai báo ý định thiết kế của mình rõ ràng hơn. Các giá trị null là cách chính xác để đại diện cho rằng một biến không đề cập đến một giá trị.

Không sử dụng tính năng này để xóa tất cả các giá trị null khỏi mã của bạn. Thay vào đó, bạn nên khai báo ý định của mình với trình biên dịch và các nhà phát triển khác đọc mã của bạn.

Bằng cách khai báo ý định của bạn, trình biên dịch sẽ thông báo cho bạn khi bạn viết mã không phù hợp với ý định đó.

Một kiểu tham chiếu nullable được khai báo bằng cách sử dụng cú pháp giống như kiểu giá trị nullable: sử dụng ký tự ? ngay sau kiểu dữ liệu của biến. Ví dụ: khai báo biến dưới đây minh họa biến name kiểu string nullable:

string? name;

Danh sách không đồng bộ

Bắt đầu từ C# 8.0, bạn có thể tạo và sử dụng danh sách không đồng bộ. Một phương thức trả về một danh sách không đồng bộ có ba thuộc tính:

  1. Nó được khai báo với từ khóa async.
  2. Nó trả về kiểu IAsyncEnumerable<T> .
  3. Phương thức chứa các câu lệnh yield return để trả về các phần tử liên tiếp trong danh sách không đồng bộ.

Việc sử dụng một danh sách không đồng bộ đòi hỏi bạn phải thêm từ khóa await trước từ khóa foreach khi bạn liệt kê các phần tử của danh sách.

Việc thêm từ khóa await yêu cầu phương thức liệt kê danh sách không đồng bộ được khai báo với từ khóa async và trả về một kiểu được phép cho một phương thức async.

Thông thường, điều đó có nghĩa là trả về một Task hoặc Task<TResult>. Nó cũng có thể là ValueTask hoặc ValueTask<TResult>.

Một phương thức có thể vừa sử dụng vừa tạo ra một danh sách không đồng bộ, có nghĩa là nó sẽ trả về IAsyncEnumerable<T>. Đoạn mã sau tạo một chuỗi từ 0 đến 19, chờ 100ms khi tạo mỗi số:

public static async System.Collections.Generic.IAsyncEnumerable<int> GenerateSequence()
{
    for (int i = 0; i < 20; i++)
    {
        await Task.Delay(100);
        yield return i;
    }
}

Bạn sẽ in danh sách bằng cách sử dụng câu lệnh await foreach:

await foreach (var number in GenerateSequence())
{
    Console.WriteLine(number);
}

Xử lý không đồng bộ

Bắt đầu từ phiên bản 8.0, C# hỗ trợ xử lý không đồng bộ (asynchronous disposable) bằng cách triển khai interface IAsyncDisposable.

Toán hạng của biểu thức using (đã được trình bày ở phía trên) có thể triển khai interface IDisposable hoặc IAsyncDisposable.

Trong trường hợp triển khai IAsyncDisposable, trình biên dịch sẽ tạo ra mã để await các Task mà phương thức IAsyncDisposable.DisposableAsync trả về.



Bài viết liên quan:

Hướng dẫn này sẽ giúp bạn tìm hiểu về đọc ghi file (File I/O) trong C# và sử dụng các lớp tiện ích để đọc ghi file.

Reflection trong C#

  • 6 min read

Reflection trong C# là gì? Ứng dụng của Reflection trong C#. Cách khai báo và sử dụng Reflection trong C#.

Attribute trong C#

  • 7 min read

Attribute trong C# là gì? Có những loại attribute nào trong C#? Làm sao để sử dụng attribute trong C#.