Tản mạn C#: LINQ

Là dân C#, sớm muộn gì ta cũng phải đụng đến LINQ. LINQ là một kỹ thuật giúp ta thực hiện truy vấn, biến đổi dữ liệu theo phong cách quen thuộc của SQL. Sử dụng LINQ, những đoạn code trước đây rườm rà giờ trở nên gọn nhẹ. Những vòng lặp dài dòng giờ chỉ còn một dòng ngắn gọn. Vì vậy, những người mới bắt đầu sẽ khá bỡ ngỡ khi dùng LINQ. Bài viết này sẽ tổng hợp lại những hàm căn bản của LINQ để giúp bạn tham khảo dễ dàng khi cần thiết.

Trong bài này, tôi chỉ đề cập đến LINQ to Object, nghĩa là dùng LINQ với collection trong .NET.

Giới thiệu LINQ

Ta chỉ dùng LINQ với kiểu IEnumerable hoặc kiểu con của nó. LINQ thực chất là một bộ extension method của IEnumerable.

Ta có thể dùng LINQ theo 2 cách:

  • Cú pháp query.
  • Cú pháp method (khuyên dùng).

Dưới đây là đoạn code thường gặp khi dùng LINQ:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System.Linq; // Must include this namespace to use LINQ.

class Program
{
    static void Main()
    {
        var numbers = new List<int> { 2, 4, 5, 6, 9 };

        // Query construction. Doesn't execute.
        var query = from number in numbers
                    where number > 5
                    select number;

        // Query will get executed here.
        foreach (var item in query)
        {
            Console.WriteLine($"{item} ");
        }
    }
}

Từ dòng 10 đến 12, tôi xây dựng một LINQ query. Cụ thể như sau:

  • Range: from number (biến này được dùng ở nhiều chỗ trong câu query)
  • Source: in numbers
  • Query: where number > 5
  • Result: select number
1
2
3
var query = from girl in girls // girls is an IEnumerable<string>
            where girl.Age > 20
            select girl.Name;
1
2
3
var query = from girl in girls
            where girl.Name == "Loan"
            select new { girl.Name, girl.Age }; // Anonymous type.

Anonymous type là kiểu do người dùng định nghĩa nhưng không đặt tên. Property của anonymous type là read-only. Giá trị của property được gán ngay lúc kiểu được định nghĩa. Ở dòng 3, tôi tạo đối tượng anonymous bằng từ khóa new. Đối tượng này sẽ có 2 property: một tên Name và một tên Age. C# tự động lấy tên biến làm tên property của đối tượng anonymous. Ta có thể đặt tên khác cho property bằng cú pháp sau:

1
2
3
4
5
6
7
var query = from girl in girls
            where girl.Age > 20
            select new
            {
                GirlName = girl.Name,
                GirlAge = girl.Age
            };

Hai đối tượng anonymous có tên và số lượng property giống nhau thì được coi là một kiểu (type). Đoạn code bên dưới sẽ minh họa điều này.

1
2
3
var hotGirl = new { Name = "Loan", Age = 25 };
var superGirl = new { Name = "Ngoc", Age = 23 };
var sameType = hotGirl.GetType() == superGirl.GetType(); // True.

Delegate

Trước khi đi vào tìm hiểu các hàm của LINQ, ta phải hiểu khái niệm delegate vì tất cả hàm của LINQ đều dùng tính năng này. Delegate có thể hiểu là con trỏ hàm (giống với khái niệm tương đương trong C++). Đoạn code bên dưới sẽ minh họa khái niệm này:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Program
{
    delegate void SayHello(string name);

    static void Hi(string name)
    {
        Console.WriteLine($"Hi {name}.");
    }

    static void Main()
    {
        SayHello say = new SayHello(Hi);
        // Can also be written as:
        // SayHello say = Hi;
        // var say = (SayHello) Hi;
        say("Huong");
    }
}

Ở dòng số 3, ta định nghĩa một delegate. Tại dòng 12, tôi gán hàm Hi() vào delegate. Hàm Hi() có chữ ký (method signature) phù hợp với delegate SayHello. Nó nhận vào một tham số kiểu string và trả về kiểu void.

Ta có thể viết gọn hơn bằng anonymous method như sau:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Program
{
    delegate void SayHello(string name);

    static void Main()
    {
        SayHello say = delegate(string name) // Anonymous method.
        {
            Console.WriteLine($"Hi {name}.");
        };

        say("Huong");
    }
}

Tuy nhiên, anonymous method đang dần rơi vào quên lãng để nhường chỗ cho biểu thức lambda. Đoạn code sẽ được viết lại như sau:

1
2
3
4
5
6
7
8
9
10
class Program
{
    delegate void SayHello(string name);

    static void Main()
    {
        SayHello say = name => Console.WriteLine($"Hi {name}.");
        say("Huong");
    }
}

Biểu thức lambda có dạng như sau:

(parameters) => expression

Trong đó, dấu => được gọi là toán tử lambda.

Biểu thức lambda được dùng rất nhiều trong các hàm LINQ nên ta cần phải thành thạo chúng để sử dụng LINQ hiệu quả.

Action<> và Func<>

Action<>Func<> là 2 delegate được dùng trong các hàm LINQ.

Action<> luôn trả về void và có thể nhận nhiều tham số. Dưới đây là đoạn code Microsoft dùng để định nghĩa delegate Action<>:

delegate void Action<in T>(T obj);

Còn đây là cách ta dùng delegate Action<>:

Action<string> say = (name) => Console.WriteLine($"Hi {name}.");
say("Loan");

Func<> luôn trả về một giá trị và kiểu giá trị trả về là tham số kiểu (type parameter) cuối cùng. Dưới đây là đoạn code Microsoft dùng để định nghĩa delegate Func<>:

delegate TResult Func<in T, out TResult>(T arg)

Còn đây là cách ta dùng delegate Func<>:

Func<string, string> say = (name) => $"Hi {name}.";
Console.WriteLine(say("Loan"));

Hàm căn bản của LINQ

Giả sử tôi có một collection 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
static void Main()
{
    var girls = new List<Girl>
    {
        new Girl("Nhung", 25),
        new Girl("Huong", 25),
        new Girl("Loan", 46),
        new Girl("Ngoc", 35)
    };

    // LINQ queries will be written here.
}

class Girl
{
    public string Name { get; set; }
    public int Age { get; set; }

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

Trong các đoạn code ví dụ bên dưới, tôi sẽ dùng hàm LINQ để truy vấn collection này.

Sắp xếp (Ordering)

Dưới đây là các hàm sắp xếp sử dụng cú pháp query:

1
2
3
4
5
6
7
8
9
10
11
var query1 = from g in girls
             orderby g.Name
             select g.Name;

var query2 = from g in girls
             orderby g.Name descending
             select g.Name;

var query3 = from g in girls
             orderby g.Name, g.Age descending
             select g.Name;

Còn đây là các hàm sắp xếp sử dụng cú pháp method:

1
2
3
4
5
6
7
8
9
var query1 = girls.OrderBy(g => g.Name)
                  .Select(g => g.Name);

var query2 = girls.OrderByDescending(g => g.Name)
                  .Select(g => g.Name);

var query3 = girls.OrderBy(g => g.Name).
                  .ThenByDescending(g => g.Age)
                  .Select(g => g.Name);

Gom nhóm (Grouping)

Sử dụng cú pháp query:

1
2
3
4
5
6
7
8
var query = from g in girls
            group g by g.Age
            select g;

foreach (var girlGroup in query)
{
    Console.WriteLine($"{girlGroup.Key}: {girlGroup.Count()}");
}

Sử dụng cú pháp method:

1
2
3
4
5
6
var query = girls.GroupBy(g => g.Age);

foreach (var girlGroup in query)
{
    Console.WriteLine($"{girlGroup.Key}: {girlGroup.Count()}");
}

Ta có thể dùng từ khóa into để tạo một biến tạm chứa kết quả sau khi gom nhóm như sau:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var query = from g in girls
            group g by g.Age
            into girlsByAge // New range value.
            where girlsByAge.Count() > 1
            select new
            {
                Age = girlsByAge.Key,
                Count = girlsByAge.Count()
            };

var query2 = girls.GroupBy(g => g.Age, g => g)
                  .Where(g => g.Count() > 1)
                  .Select(g => new
                  {
                      Age = g.Key,
                      Count = g.Count()
                  });

foreach (var girlGroup in query)
{
    Console.WriteLine($"{girlGroup.Age}: {girlGroup.Count}");
}

Hàm thường dùng

1
2
3
4
5
6
girls.Where(g => g.Age > 20);
girls.OrderBy(g => g.Name);
girls.OrderByDescending(g => g.Name);
girls.Where(g => g.Age > 20).OrderBy(g => g.Name);
girls.OrderBy(g => g.Name).ThenBy(g => g.Age);
girls.Select(g => new { g.Name, g.Age });

Quantifiers (Any, All, Contains)

Tất cả hàm thuộc loại này đều trả về kiểu bool.

Hàm Any() kiểm tra xem có tồn tại bất kì phần tử nào thỏa mãn điều kiện hay không. Biểu thức điều kiện được truyền vào dưới dạng Func<>.

1
2
girls.Any(g => g.Name == "Loan");
girls.Any(); // Check if collection contains any element.

Hàm All() kiểm tra xem tất cả phần tử trong collection có thỏa mãn điều kiện hay không. Tương tự như Any(), biểu thức điều kiện được truyền vào dưới dạng Func<>.

1
girls.All(g => g.Name != "Loan");

Hàm Contains() kiểm tra xem collection có chứa phần tử nào đó hay không. Tuy nhiên, hàm này không nhận Func<> mà nhận đối tượng để so sánh.

1
girls.Contains(new Girl("Loan", 25));

Likely to be true early? Use Any

Likely to be false early? Use All

Element operators

Return a single element from the collection

1
2
3
4
5
6
7
8
9
10
11
12
13
14
girls.Where(g => g.Name == "Nhung").Single();
girls.Single(g => g.Name == "Nhung"); // Throw exception if not found or more than 1
girls.SingleOrDefault(g => g.Name == "Nhung");
// If not match, return default value of the type
// If more than 1, throw exception

girls.First();
girls.Last();
girls.First(g => g.Name == "Loan"); // Throw exception if no match
// Should use FirstOrDefault() or LastOrDefault()

girls.ElementAt(2) // Like girls[2]
// Use this syntax to access element of LINQ query
// Throw exception if the index is out of range, use ElementAtOrDefault()

Partitioning operators

Hàm Take(), Skip(), TakeWhile(), và SkipWhile().

1
2
3
4
5
girls.Take(3);
girls.OrderBy(g => g.Name.Length).Take(3);
girls.OrderBy(g => g.Name.Length).Skip(3).Take(3);
girls.TakeWhile(g => g.Name.Length < 6);
girls.SkipWhile(g => g.Name.Length < 6);

Hàm Join()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var hotGirlNames = new List<string>
{
    "Nhung", "Ngoc", "Loan"
};

var hotGirls = from g in girls
               join n in hotGirlNames
               on g.Name equals n
               select g;

var hotGirls = girls.Join(hotGirlNames,
                          g => g.Name,
                          n => n,
                          (girl, name) => girl);
1
2
3
4
outer.Join(inner,
           Func<TOuter, TKey> outKeySelector,
           Func<TInner, TKey> innerKeySelector,
           Func<TOuter, TInner, TResult> resultSelector);
1
2
3
4
5
6
7
8
var hotGirls = girls.Join(hotGirlNames,
                          g => g.Name,
                          n => n
                          (girl, name) => new
                          {
                              Name = name,
                              Girl = girl
                          });

Hàm GroupJoin() không phổ biến bằng Join().

1
2
3
4
5
6
7
8
9
10
11
var groupGirls = ages.GroupJoin(girls,
                                a => a,
                                g => g.Age,
                                (age, girl) => new
                                {
                                    Age = age,
                                    Girls = girl
                                });
groupGirls.Select(g => g.Age);
groupGirls.Where(g => g.Age > 20).Select(g => g.Girls);
groupGirls.SelectMany(g => g.Girls);

Aggregates

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from g in girls
group g by g.Age;

girls.GroupBy(g => g.Age);
girls.GroupBy(g => g.Age)
     .Select(g => new
     {
         Age = g.Key,
         Count = g.Count()
     });

girls.Sum(g => g.Age);
girls.GroupBy(g => g.Age)
     .Select(g => new
     {
         Age = g.Age,
         TotalAge = g.Sum(b => b.Age)
     });

girls.Average(g => g.Age);
girls.Min(g => g.Age);
girls.Max(g => g.Age);

Hàm tập hợp (Set operations)

Distinct()

1
girls.Select(g => g.Age).Distinct();

Except()

1
2
var ages = new List<int> { 20, 30, 40 };
ages.Except(girls.Select(g => g.Age));

Union()

1
ages.Union(girls.Select(g => g.Age));

Intersect()

1
ages.Intersect(girls.Select(g => g.Age));

Concat()

1
ages.Concat(girls.Select(g => g.Age).Distinct())

Generation operators

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Range(start, count) return 0 to 9.
Enumerable.Range(0, 10);

// Return 10 to 19.
Enumerable.Range(10, 10);

// Return a collection which contains 10 values of "Name".
Enumerable.Repeat("Name", 10);

// Return a collection of 5 girls.
Enumerable.Repeat(new Girl(), 5);

// Return empty collection (has no element).
var emptyGirls = Enumerable.Empty<Girl>();
var numbers = Enumerable.Empty<int>();

// Return a collection that contains 1 item with default value.
// Result will contain 1 element of value 0.
var result = numbers.DefaultIfEmpty();

Hàm chuyển đổi kiểu (Conversion)

Tất cả hàm LINQ to Object đều trả về kiểu IEnumerable<T>. Nếu muốn chuyển sang kiểu List<T> hoặc Array, ta dùng hàm chuyển đổi của LINQ: ToList()ToArray().

1
2
girls.Where(g => g.Age == 30).ToList();
girls.ToArray();

Cách chạy của hàm LINQ

Dựa theo cách thức chạy (manner of execution), ta có thể phân loại hàm LINQ thành 2 nhóm chính:

  • Immediate execution (Chạy tức thì)
  • Deferred execution (Chạy trì hoãn)
    • Streaming
    • Non-streaming

Immediate execution (Chạy tức thì)

Hàm sẽ chạy ngay khi câu query được định nghĩa.

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 query = Fibonacci(10);
    foreach (var item in query)
    {
        Console.Write($"{item} ");
    }
}

static IEnumerable<int> Fibonacci(int number)
{
    var sequence = new List<int>();
    var previous = 0;
    var current = 1;

    for (var i = 0; i < number; i++)
    {
        sequence.Add(current);

        var temp = current;
        current += previous;
        previous = temp;
    }

    return sequence;
}

Deferred execution (Chạy trì hoãn)

Hàm sẽ chạy khi câu query được duyệt qua (như trong foreach). Deferred execution chia làm 2 loại: streaming và non-streaming.

Streaming

Không phải xử lý toàn bộ dữ liệu nguồn trước khi trả ra kết quả. Xử lý xong phần tử nào thì trả về ngay phần tử đó.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static void Main()
{
    var query = Fibonacci(10);
    foreach (var item in query)
    {
        Console.Write($"{item} ");
    }
}

static IEnumerable<int> Fibonacci(int number)
{
    var previous = 0;
    var current = 1;

    for (var i = 0; i < number; i++)
    {
        yield return current;

        var temp = current;
        current += previous;
        previous = temp;
    }
}

Non-streaming

Phải xử lý toàn bộ dữ liệu trước khi trả ra kết quả. Thường thì nó sẽ đọc toàn bộ dữ liệu nguồn, bỏ vào cấu trúc dữ liệu nào đó (như List<T>), xử lý các phần tử, rồi trả ra kết quả.

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
static void Main()
{
    var query = Fibonacci(10);
    foreach (var item in query)
    {
        Console.Write($"{item} ");
    }
}

static IEnumerable<int> Fibonacci(int number)
{
    var sequence = new List<int>();
    var previous = 0;
    var current = 1;

    for (var i = 0; i < number; i++)
    {
        sequence.Add(current);

        var temp = current;
        current += previous;
        previous = temp;
    }

    foreach (var item in sequence)
    {
        yield return item;
    }
}

Tổng kết

Dưới đây là bảng tổng kết các hàm LINQ thông dụng được trình bày trong bài viết này.

Quantifiers
Any, All, Contains
Elements
First, Single, Last, ElementAt
FirstOrDefault, SingleOrDefault, LastOrDefault, ElementAtOrDefault
Partitioning
Take, Skip, TakeWhile, SkipWhile
Joins
Join, GroupJoin
Aggregates
Count, Sum, Average, Min, Max
Set operators
Distinct, Except, Union, Intersect
Generation
Range, Repeat, Empty, DefaultIfEmpty
Conversion
ToList, ToArray