Khám phá C# 7

Trong khi ta chưa dùng hết tính năng mới của C# 6, Microsoft lại tung ra C# 7 kèm theo Visual Studio 2017. Trong bài này, tôi sẽ trình bày vài tính năng nổi bật của nó.

Để dùng tính năng của C# 7, bạn phải cài Visual Studio 2017.

Phân cách giá trị số

Với C# 7, ta có thể dùng dấu gạch dưới để phân cách các giá trị số. Dấu phân cách được phép nằm ở vị trí bất kỳ.

1
2
3
4
var n1 = 1_234_567;
var n2 = 12_34_56_7;

Console.WriteLine(n1 == n2); // True.

C# trước đây đã hỗ trợ viết số thập lục phân bằng cách thêm tiền tố 0x phía trước giá trị số (0xABC). Giờ đây, nó hỗ trợ thêm viết số hệ nhị phân bằng tiền tố 0b (0b10001000) và ta cũng có thể dùng dấu phân cách cho những con số này (0b1000_1000).

Hàm cục bộ (Local function)

Hàm cục bộ là hàm nằm trong một hàm khác. Tất cả tham số và biến của hàm ngoài đều được nhìn thấy từ hàm cục bộ. Ta có thể đặt hàm cục bộ ở bất kì đâu trong một hàm khác. Để dễ quản lý, tôi thường đặt nó ở cuối hàm.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static void Main()
{
    Console.WriteLine(Factorial(5));

    foreach (var item in Fibonacci(10))
    {
        Console.Write($"{item} ");
    }

    int Factorial(int number) =>
        number == 1 ? 1 : number * Factorial(number - 1);

    IEnumerable<int> Fibonacci(int count)
    {
        var (previous, current) = (0, 1);
        for (var i = 0; i < count; i++)
        {
            yield return current;
            (previous, current) = (current, current + previous);
        }
    }
}

Ở đoạn code trên, tôi có 2 hàm cục bộ. Hàm đầu tiên tính giai thừa (Factorial), hàm còn lại sinh ra dãy Fibonacci. Đặc biệt, trong hàm thứ hai, tôi có dùng tính năng Value Tuple của C# 7. Tính năng này sẽ được trình bày ở phần kế tiếp.

Value tuple

Tuple là kiểu dữ liệu có từ lâu trong C#. Tuy nhiên, tuple này là kiểu tham chiếu (reference type) và không thể thay đổi (immutable) sau khi gán giá trị. Trong C# 7, Microsoft giới thiệu một loại tuple mới gọi là value tuple. Sở dĩ có chữ “value” trong tên gọi vì nó là kiểu giá trị (value type). Đặc biệt hơn, nó có thể thay đổi (mutable) sau khi gán giá trị.

Hàm chỉ có thể trả về một giá trị. Nếu muốn trả về nhiều giá trị, ta phải đóng gói (encapsulate) chúng vào một object, rồi return nó. Tạo một class riêng chỉ để chứa giá trị trả về thì hơi phiền phức. Do đó, ta thường dùng kiểu tuple có sẵn để chứa nhiều giá trị trả về.

Kiểu tuple truyền thống (System.Tuple<T>) có một điểm yếu là cú pháp của nó rườm rà, chưa kể khi lấy giá trị, ta phải dùng biến có tên mơ hồ Item1 hay Item2. Do nó là kiểu referenceimmutable, khi muốn thay đổi giá trị, ta phải tạo đối tượng tuple mới.

1
2
3
4
5
6
7
var person = new Tuple<string, int>("Hieu", 30);
person = Tuple.Create("John", 26);

Console.WriteLine(person.Item1);
Console.WriteLine(person.Item2);

person.Item1 = "Jane"; // Won't compile.

Ngoài cách dùng tuple để trả về nhiều giá trị, ta có thể dùng biến output với từ khóa out. Tuy nhiên, cách này không hoạt động với hàm async.

Cách cuối cùng là dùng kiểu trả về dynamic. Từ khóa dynamic báo cho C# compiler biết rằng nó không cần kiểm tra kiểu dữ liệu lúc biên dịch sang IL. Điều này mang đến sự linh hoạt, nhưng đồng thời cũng làm mất đi lợi thế vốn có của C#: tính an toàn kiểu (type safety).

Value tuple có thể được truyền vào hàm dưới dạng tham số, hoặc được trả về từ hàm. Để tạo value tuple, ta dùng cú pháp đặc biệt như sau:

1
2
3
4
static (int, int, int) GetDate()
{
    return (2018, 6, 20); // Tuple literal.
}

Ta dùng cặp dấu ngoặc đơn () để tạo value tuple. Ở dòng 1, chỗ khai báo giá trị trả về, tôi dùng (int, int, int) để báo cho compiler biết rằng hàm này sẽ trả về tuple gồm 3 số integer. Ở dòng 3, tôi tạo một tuple (2018, 6, 20). C# sẽ tự động kiểm tra xem giá trị của tuple tôi nhập vào có đúng kiểu hay không.

Ngoài ra, tôi có thể đặt tên biến cho từng giá trị của tuple.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void Main()
{
    var date = GetDate();

    Console.WriteLine(date.year);
    Console.WriteLine(date.month);
    Console.WriteLine(date.day);

    date.year = 2020;
}

static (int year, int month, int day) GetDate()
{
    return (2018, 7, 13);
}

Trong hàm Main(), tôi có thể dùng tên biến (year, month, day) để lấy ra giá trị của tuple mà không cần đến cái tên mơ hồ như Item1 hay Item2. Tất cả phần tử trong value tuple đều publicmutable, nghĩa là ta có thể thay đổi giá trị của chúng như ở dòng 9.

Value tuple có thể dùng ở bất kì đâu, kể cả làm key cho Dictionary như đoạn code sau đây:

1
2
3
4
5
var dict = new Dictionary<(int, int), string>();
dict.Add((100, 20), "Something here");
dict.Add((200, 40), "Something there");

var result = dict[(100, 20)];

Phân rã tuple (tuple deconstruction) là cách gán giá trị của các phần tử trong tuple sang biến bên ngoài.

1
2
3
4
5
6
7
(var year, var month, var day) = GetDate();
// Can also be written as:
// (int year, int month, int day) = GetDate();

Console.WriteLine(year);
Console.WriteLine(month);
Console.WriteLine(day);

Ở đây, tôi dùng từ khóa var cho từng phần tử của tuple. Bên dưới, tôi có thể dùng chúng như những biến bình thường. Ngoài ra, tôi cũng có thể lôi từ khóa var ra ngoài như sau:

1
2
3
4
5
var (year, month, day) = GetDate();

Console.WriteLine(year);
Console.WriteLine(month);
Console.WriteLine(day);

Chưa hết, ta cũng có thể dùng biến đã khai báo sẵn để hứng giá trị từ tuple.

1
2
3
4
5
6
7
8
9
int year;
int month;
int day;

(year, month, day) = GetDate();

Console.WriteLine(year);
Console.WriteLine(month);
Console.WriteLine(day);

Nếu không quan tâm giá trị day, tôi có thể bỏ qua nó bằng kí tự _. Giá trị bị bỏ qua được gọi là discard.

1
2
3
4
var (year, month, _) = GetDate();

Console.WriteLine(year);
Console.WriteLine(month);

Phân rã đối tượng (object deconstruction) là cách gán dữ liệu bên trong đối tượng sang biến bên ngoài.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
static void Main()
{
    var student = new Student("John", 34);
    var (name, age) = student;

    Console.WriteLine(name);
    Console.WriteLine(age);
}

class Student
{
    public string Name { get; }
    public int Age { get; }

    public Student(string name, int age)
    {
        Name = name;
        Age = age;
    }

    public void Deconstruct(out string name, out int age)
    {
        name = Name;
        age = Age;
    }
}

static class StudentExtension
{
    public static void Deconstruct(this Student student,
                                   out string name,
                                   out int age)
    {
        name = student.Name;
        age = student.Age;
    }
}

Hàm Deconstruct() ở dòng 21 chứa các tham số out. C# sẽ dùng hàm này để lấy dữ liệu ra ngoài và gán vào biến nameage ở dòng 4. Hàm Deconstruct() có thể là extension method như thể hiện ở dòng 30. Ta cũng có thể tạo nhiều overload cho hàm Deconstruct(). Điều kiện bắt buộc là số lượng tham số của các overload phải khác nhau.

So khớp mẫu (Pattern matching)

So khớp mẫu là cách để kiểm tra giá trị có khớp với một mẫu (pattern) cho trước hay không. Trước đây, toán tử is được dùng để kiểm tra một đối tượng có phải là một kiểu xác định. Ví dụ như để kiểm tra biến number có chứa giá trị kiểu int hay không, ta gõ number is int. Với C# 7, ta có thể thay vế phải của is bằng một pattern.

Pattern chia làm 3 loại: Constant, Type, và Var.

Đoạn code sau sẽ minh hoạ cả 3 loại pattern khi dùng với toán tử is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static void Main()
{
    Print(null);
    Print(3); // Print "3".
    Print(new string[] { null, "Hieu" }); // Print "null".
}

static void Print(object value)
{
    if (value is null) return; // Constant pattern.
    if (value is int number) // Type pattern.
    {
        Console.WriteLine(number);
    }

    if (value is string[] names &&
        names.First() is var name) // Var pattern.
    {
        Console.WriteLine(name);
    }
}

Câu lệnh ở dòng 11 có nghĩa là nếu value là kiểu int thì ép kiểu về int và gán giá trị đó vào biến number. Chỉ với 1 biểu thức điều kiện của if, tôi có thể làm 3 việc cùng lúc: kiểm tra kiểu của biến (value có phải kiểu int hay không), ép kiểu biến sang kiểu khác (ép value sang kiểu int), và gán giá trị cho biến mới (biến number). Ở dòng 13, tôi dùng biến number như bình thường.

Tính năng này thường được dùng để tạo biến tạm trong một biểu thức:

1
2
3
4
5
6
7
8
static void Print(object value)
{
    if (value is int number ||
        value is string text && int.TryParse(text, out number))
    {
        Console.WriteLine(number);
    }
}

Trong đoạn code trên, biến numbertext là 2 biến tạm được tạo ra ngay trong biểu thức điều kiện của mệnh đề if.

Nếu là người dùng C# lâu năm, ta biết rằng các mệnh đề case trong cấu trúc switch chỉ chấp nhận hằng số (constant). Nếu muốn so với biểu thức điều kiện, ta buộc phải chuyển sang dùng if...else. Ở phiên bản 7, C# cho phép ta dùng biểu thức điều kiện trong switch...case như sau:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
static void Main()
{
    Employee employee = new President(7000);
    switch (employee)
    {
        case President p when p.Salary >= 5000:
            Console.WriteLine($"High salary: ${p.Salary}");
            break;
        case President p when p.Salary < 5000:
            Console.WriteLine($"Good salary: ${p.Salary}");
            break;
        case Manager m:
            Console.WriteLine($"Medium salary: ${m.Salary}");
            break;
        case Employee e:
            Console.WriteLine($"Low salary: ${e.Salary}");
            break;
        case null:
            throw new ArgumentNullException();
    }
}

class Employee
{
    public int Salary { get; }
    public Employee(int salary) => Salary = salary;
}

class Manager : Employee
{
    public Manager(int salary) : base(salary) {}
}

class President : Manager
{
    public President(int salary) : base(salary) {}
}

Ở dòng 3, tôi phải gán kiểu cho biến là Employee, kiểu cha của ManagerPresident. Nếu không, C# sẽ không chịu và báo lỗi ở case cuối cùng:

CS8120: The switch case has already been handled by a previous case.

Tôi sử dụng từ khóa when để đặt điều kiện cho case. Chỉ khi điều kiện trong when thỏa mãn thì đoạn code của case mới được thực thi.

Trả về bằng tham chiếu

Ta đã quá quen thuộc với khái niệm truyền tham số dưới dạng tham chiếu (pass by reference). C# 7 giới thiệu thêm khái niệm mới cũng tương tự, đó là trả về bằng tham chiếu (return by reference).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static void Main()
{
    var numbers = new[] { 1, 2, 3, 4 };
    ref var result = ref Search(3, numbers);
    result = 99;

    foreach (var item in numbers)
    {
        Console.Write($"{item} ");
    }

    // Output: 1 2 99 4
}

static ref int Search(int target, int[] numbers)
{
    for (int i = 0; i < numbers.Length; i++)
    {
        if (numbers[i] == target)
        {
            return ref numbers[i];
        }
    }

    throw new Exception("Target could not be found.");
}

Hàm Search() thực hiện tìm kiếm giá trị target. Khi tìm được, nó trả về một reference đến phần tử chứa giá trị target. Ở dòng 21, tôi dùng từ khóa ref sau return để trả một tham chiếu (thay vì trả giá trị như thông thường). Ở dòng 15, ngay chỗ khai báo kiểu giá trị trả về, tôi cũng thêm từ khóa ref phía trước. Ở dòng 4, khi gọi hàm Search(), tôi cũng phải dùng ref cho cả 2 phía của toán tử gán. Tại dòng 5, tôi gán 99 vào biến result. Vì đây là một tham chiếu đến phần tử thứ 3 của mảng, phần tử này bị thay đổi giá trị thành 99.

Biến out

Thông thường, danh sách tham số của một hàm là để nhận giá trị từ ngoài truyền vào. Khi muốn trả giá trị về, ta dùng từ khóa return. Tuy nhiên, trong trường hợp ta muốn trả nhiều giá trị, lập trình viên thường dùng một tham số đặc biệt đánh dấu bằng từ khóa out. Từ khóa out cho C# biết tham số này là để đẩy giá trị ra ngoài.

Khi gọi hàm có dùng biến output, từ khóa out khiến cách dùng trở nên rườm rà. Trước tiên, ta phải tạo một biến có kiểu tương ứng với kiểu của tham số output. Sau đó, ta gọi hàm và sử dụng từ khóa out ở trước tham số. Từ khóa out phải được dùng khi định nghĩa hàm và khi gọi hàm.

Với C# 7, ta có thêm tính năng khai báo biến output ngay trong danh sách tham số khi gọi hàm. Hãy xem đoạn code sau đây:

1
2
3
4
5
6
7
8
9
10
11
12
static void Main()
{
    GetDate(out int year, out int month, out int day);
    Console.WriteLine($"{day}/{month}/{year}");
}

static void GetDate(out int year, out int month, out int day)
{
    year = 2018;
    month = 6;
    day = 15;
}

Như vậy, ta có thể khai báo biến output ngay trong lúc gọi hàm. Tại dòng số 3, khi gọi hàm GetDate(), tôi tạo luôn 3 biến year, monthday để hứng giá trị trả ra. Ở dòng 4, tôi dùng 3 biến này như bình thường.

Tôi cũng có thể không khai bao kiểu cụ thể mà dùng từ khóa var như sau:

1
2
3
4
5
static void Main()
{
    GetDate(out var year, out var month, out var day);
    Console.WriteLine($"{day}/{month}/{year}");
}

Tính năng này rất hữu ích khi parse dữ liệu.

1
2
3
4
5
6
7
8
9
10
11
12
13
var number = "13a";

if (int.TryParse(number, out var result))
{
    Console.WriteLine(result);
}
else
{
    Console.WriteLine(result); // Result can be seen here.
    Console.WriteLine("Cannot parse the value.");
}

Console.WriteLine(result); // It can also be seen here.

Đặc biệt, biến result có thể được nhìn thấy trong phần else và cả phần code nằm ngoài if...else như thể hiện trong đoạn code trên.

Dùng biểu thức để định nghĩa constructor và property

C# 6 cho phép ta dùng toán tử lambda để khai báo thân hàm vắn tắt. Tuy nhiên, đối với constructor và property, ta không thể dùng tính năng tiện ích này. Giờ đây, với C# 7, ta đã có thể làm điều đó.

1
2
3
4
5
6
7
8
9
10
11
class Student
{
    private string name;
    public string Name
    {
        get => name;
        set => name = value ?? throw new ArgumentNullException();
    }

    public Student(string name) => Name = name ?? throw null;
}

Ở dòng 6 và 7, tôi dùng biểu thức lambda để định nghĩa phần thân hàm cho getter và setter. Còn ở dòng 10, tôi cũng dùng biểu thức lambda để thay cho phần thân hàm constructor.

Ghi chú: throw null tương đương throw new ArgumentNullException().

Quăng exception trong biểu thức

Trước đây, quăng exception được coi là một câu lệnh (statement). Do đó, ta không thể dùng nó như một biểu thức (expression). Điều này khá bất tiện vì câu lệnh phải đứng riêng, không được dùng trong câu lệnh khác.

Với C# 7, ta có thể quăng exception trong một biểu thức. Đoạn code sau đây sẽ vừa gán giá trị cho biến và quăng exception trong cùng một câu lệnh:

1
2
3
4
5
6
7
public class Employee
{
    public string Name { get; }

    public Employee(string name) =>
        Name = name ?? throw new ArgumentNullException();
}

Ở dòng 6, throw new ArgumentNullException() được dùng như một biểu thức trong câu lệnh gán giá trị cho Name. Tính năng mới này giúp tiết kiệm phím gõ. Thay vì phải viết thêm điều kiện kiểm tra null rồi quăng exception, giờ đây ta viết nó ngay trong lệnh gán.