Nội dung

Tất Tần Tật về Generics trong C#: Hiểu Sâu, Xài Chuẩn, Viết Code Xịn

Sê-ri - Lập trình C#

Bạn đã bao giờ cảm thấy bực mình khi phải viết đi viết lại những đoạn code chỉ khác nhau về kiểu dữ liệu? Hay gặp cảnh lỗi “sai kiểu” mà không biết vì sao? Nếu có, đã đến lúc bạn làm chủ Generics – “chiêu thức bí mật” giúp code C# trở nên linh hoạt, an toàn và mạnh mẽ hơn bao giờ hết.

Bài viết này sẽ dẫn bạn từ căn bản đến nâng cao, giải thích dễ hiểu, có ví dụ thực tế, và đặc biệt là các bài tập nhỏ giúp bạn hiểu sâu – làm được ngay lập tức. Cùng khám phá sức mạnh của Generics để viết code vừa “ngầu” vừa chuyên nghiệp nhé!

Generics trong C# là một tính năng giúp bạn viết code linh hoạt, tái sử dụng được cho nhiều kiểu dữ liệu khác nhau, vừa tiết kiệm thời gian, vừa đảm bảo an toàn kiểu dữ liệu (type safety). Từ .NET Framework 2.0, Generics là trụ cột của mọi hệ thống code C# hiện đại, đặc biệt trong các thư viện, bộ sưu tập như List, Dictionary, v.v.

Tại sao nên dùng Generics?

  • Tái sử dụng code cho nhiều kiểu khác nhau mà không cần copy-paste.
  • Tránh lỗi runtime do sai kiểu (type safety) nhờ kiểm tra ngay từ compile time.
  • Dễ bảo trì, mở rộng, giảm code thừa.

Định nghĩa: Generics cho phép bạn định nghĩa Class, Method hoặc Interface với “kiểu dữ liệu chưa xác định”, gọi là kiểu tham số (type parameter).

Ví dụ: Generic Class cơ bản

public class MyGenericClass<T>
{
    public T MyMethod(T input)
    {
        Console.WriteLine($"Input value: {input}");
        return input;
    }
}

// Sử dụng:
var intClass = new MyGenericClass<int>();
intClass.MyMethod(42);

var stringClass = new MyGenericClass<string>();
stringClass.MyMethod("Xin chào, Generics!");

Giải thích:

  • <T> là kiểu tham số, đại diện cho bất cứ kiểu nào.
  • Khi dùng, bạn “gắn” kiểu cụ thể vào: MyGenericClass<int> hoặc MyGenericClass<string>.

So sánh với không dùng Generics:

Nếu không dùng Generic Khi dùng Generic
Phải viết nhiều code trùng lặp Viết 1 lần, dùng mọi kiểu
Phải ép kiểu (casting) => dễ lỗi Type Safety – phát hiện lỗi từ lúc compile
Kém hiệu suất (boxing/unboxing struct) Tránh boxing, code tối ưu hơn

Ví dụ so sánh:

// Nếu không dùng Generics
public class IntClass { public int Value; }
public class StringClass { public string Value; }

// Dùng Generics
public class GenericClass<T> { public T Value; }

Đã ví dụ ở trên: public class MyGenericClass<T> { ... }

public void MyGenericMethod<T>(T input)
{
    Console.WriteLine(input);
}

Dùng trong cả class thường hoặc class generic đều được.

public interface IMyGenericInterface<U>
{
    U GetDefaultValue();
}

// Implement
public class StringDefault : IMyGenericInterface<string>
{
    public string GetDefaultValue() => "Default String";
}

Constraint giúp bạn kiểm soát kiểu dữ liệu nào được phép truyền vào cho T.

Constraint Ý nghĩa
where T : class T phải là reference type(tham chiếu)
where T : struct T phải là value type
where T : new() T phải có constructor không tham số
where T : BaseClass T phải kế thừa BaseClass
where T : InterfaceName T phải implement interface đó

Ví dụ:

public class ConstraintsDemo<T> where T : class, new()
{
    public void MyConstrainedMethod(T input)
    {
        if (input == null)
        {
            input = new T();
            Console.WriteLine("Created a new instance of type T.");
        }
    }
}

Lưu ý: Nếu truyền kiểu không phù hợp constraint sẽ lỗi ngay từ lúc compile.


List<int> numbers = new List<int> { 10, 20, 30 };
numbers.Add(40);
numbers.Remove(20);
  • Ưu điểm: Dễ thao tác, tự động mở rộng, hỗ trợ nhiều phương thức LINQ.
Dictionary<string, int> ages = new Dictionary<string, int>
{
    { "Alice", 25 },
    { "Bob", 30 }
};

ages["Alice"] = 26;
ages.Remove("Bob");
HashSet<string> uniqueNames = new HashSet<string>();
uniqueNames.Add("Alice");
uniqueNames.Add("Bob");
uniqueNames.Add("Alice"); // Không thêm trùng lặp

Queue<T> dùng để lưu trữ các phần tử theo nguyên tắc vào trước ra trước (FIFO – First-In, First-Out).

Ví dụ:

Queue<string> queue = new Queue<string>();
queue.Enqueue("A");
queue.Enqueue("B");
queue.Enqueue("C");

Console.WriteLine(queue.Dequeue()); // "A"
Console.WriteLine(queue.Peek());    // "B" (xem trước phần tử đầu, không lấy ra)

Ứng dụng:

  • Hệ thống đặt hàng, xử lý yêu cầu, in ấn, hoặc mọi nơi cần xử lý “tới lượt ai thì làm trước”.

Stack<T> hoạt động theo nguyên tắc vào sau ra trước (LIFO – Last-In, First-Out).

Ví dụ:

Stack<string> stack = new Stack<string>();
stack.Push("A");
stack.Push("B");
stack.Push("C");

Console.WriteLine(stack.Pop());  // "C"
Console.WriteLine(stack.Peek()); // "B" (xem trước phần tử trên cùng, không lấy ra)

Ứng dụng:

  • Undo/Redo trong phần mềm, duyệt cây, tính toán biểu thức, lưu trữ trạng thái tạm thời.

Delegate là gì?

Delegate trong C# giống như “con trỏ hàm” trong C/C++, giúp bạn truyền một hàm/method như một đối tượng, từ đó có thể gọi lại (callback) hoặc thay đổi logic động.

Generic Delegate cho phép bạn định nghĩa delegate mà kiểu tham số và kiểu trả về là generic (chưa xác định trước), giúp delegate “đa năng” cho mọi loại dữ liệu.

Ví dụ: Generic Delegate

// Định nghĩa generic delegate
public delegate TResult Transformer<TInput, TResult>(TInput input);

// Dùng delegate với nhiều kiểu khác nhau
class Program
{
    static string IntToString(int n) => $"Số: {n}";
    static int StringLength(string s) => s.Length;

    static void Main()
    {
        Transformer<int, string> transf1 = IntToString;
        Console.WriteLine(transf1(10)); // Số: 10

        Transformer<string, int> transf2 = StringLength;
        Console.WriteLine(transf2("Generics")); // 8
    }
}

Giải thích:

  • Transformer<TInput, TResult> có thể dùng cho bất kỳ kiểu nào, miễn đúng “chữ ký”.
  • Rất mạnh khi muốn tái sử dụng logic (ví dụ: chuyển đổi, xử lý dữ liệu…)

Ứng dụng thực tế:

  • Các hàm LINQ như Select, Where, Aggregate đều sử dụng delegate generic (Func/Action).
  • Gắn sự kiện động, inject logic theo ý muốn mà không phải “hard code”.

Event là gì?

Event là một cách để một đối tượng thông báo cho đối tượng khác biết “có chuyện gì vừa xảy ra”. Delegate là nền tảng của event.

Generic Event giúp sự kiện truyền đi dữ liệu với kiểu linh hoạt, tuỳ vào ngữ cảnh sử dụng.

Ví dụ: Tạo Generic Event

// Định nghĩa event generic
public class ValueChangedEventArgs<T> : EventArgs
{
    public T OldValue { get; }
    public T NewValue { get; }
    public ValueChangedEventArgs(T oldVal, T newVal)
    {
        OldValue = oldVal;
        NewValue = newVal;
    }
}

public class Notifier<T>
{
    // Sử dụng EventHandler generic
    public event EventHandler<ValueChangedEventArgs<T>> ValueChanged;

    private T _value;
    public T Value
    {
        get => _value;
        set
        {
            if (!Equals(_value, value))
            {
                var old = _value;
                _value = value;
                ValueChanged?.Invoke(this, new ValueChangedEventArgs<T>(old, value));
            }
        }
    }
}

Sử dụng:

class Program
{
    static void Main()
    {
        var notifier = new Notifier<int>();
        notifier.ValueChanged += (s, e) =>
        {
            Console.WriteLine($"Giá trị thay đổi: {e.OldValue} -> {e.NewValue}");
        };

        notifier.Value = 5;
        notifier.Value = 10;
    }
}
// Output:
// Giá trị thay đổi: 0 -> 5
// Giá trị thay đổi: 5 -> 10

  • Covariance cho phép dùng kiểu dẫn xuất (subtype) ở nơi yêu cầu kiểu cơ sở (base type), thường dùng với output (trả về).
  • Contravariance cho phép dùng kiểu cơ sở ở nơi yêu cầu kiểu dẫn xuất, thường dùng với input (tham số).

Ví dụ đơn giản:

IEnumerable<string> strings = new List<string>();
IEnumerable<object> objects = strings; // Covariant OK

Action<object> actionObj = o => Console.WriteLine(o);
Action<string> actionStr = actionObj; // Contravariant OK

Ứng dụng: Đặc biệt quan trọng khi dùng interface, delegate với generics trong các trường hợp cần hỗ trợ nhiều kiểu liên quan.


  • Luôn đặt constraint nếu muốn kiểm soát kiểu dữ liệu.
  • Sử dụng Generics để tái sử dụng, nhưng tránh over-engineering (đừng generic hóa mọi thứ nếu không cần thiết).
  • Tài liệu hóa rõ các constraint, mục đích method/class, giúp team dễ hiểu và bảo trì.
  • Kiểm thử (test) kỹ với nhiều kiểu dữ liệu khác nhau.
  • Tận dụng các collection generics sẵn có trong .NET thay vì tự viết lại từ đầu.

Tạo 1 class Repository làm việc được với mọi Entity:

public interface IRepository<T>
{
    T GetById(int id);
    void Add(T entity);
    void Update(T entity);
    void Delete(int id);
}

public class UserRepository : IRepository<User>
{
    // Triển khai với Entity User...
}

Giải thích:

  • Factory Pattern giúp ẩn logic khởi tạo object khỏi client.
  • Với Generics, bạn chỉ cần viết 1 lần, dùng cho mọi loại object có constructor mặc định (new()).

Ví dụ:

public class Factory<T> where T : new()
{
    public T CreateInstance()
    {
        return new T();
    }
}

class User { public string Name { get; set; } }
class Product { public string Title { get; set; } }

class Program
{
    static void Main()
    {
        var userFactory = new Factory<User>();
        User user = userFactory.CreateInstance();
        user.Name = "Alice";
        Console.WriteLine(user.Name);

        var productFactory = new Factory<Product>();
        Product product = productFactory.CreateInstance();
        product.Title = "Laptop";
        Console.WriteLine(product.Title);
    }
}

Điểm mạnh: Chỉ cần 1 Factory<T>, bạn tạo được instance cho bất kỳ kiểu nào – không cần viết lại từng Factory riêng lẻ.


Giải thích:

  • Singleton đảm bảo chỉ có duy nhất một object tồn tại với mỗi kiểu T.
  • Generics giúp bạn tạo Singleton cho bất cứ kiểu nào, không lặp code.

Ví dụ:

public class Singleton<T> where T : new()
{
    private static T _instance;
    private static readonly object _lock = new object();

    private Singleton() { }

    public static T Instance
    {
        get
        {
            if (_instance == null)
            {
                lock (_lock)
                {
                    if (_instance == null)
                        _instance = new T();
                }
            }
            return _instance;
        }
    }
}

class Config { public string AppName = "DemoApp"; }

class Program
{
    static void Main()
    {
        var config1 = Singleton<Config>.Instance;
        var config2 = Singleton<Config>.Instance;
        config1.AppName = "MyApp";
        Console.WriteLine(config2.AppName); // Kết quả: "MyApp" (cùng instance!)
    }
}

Điểm mạnh:

  • Không cần viết lại logic Singleton cho từng class khác nhau.
  • Dễ mở rộng và áp dụng cho mọi loại service, entity, config, cache…

Giải thích:

  • Strategy Pattern giúp bạn thay đổi thuật toán hoặc hành vi runtime một cách “cắm/rút” (plug-and-play).
  • Với Generics, bạn viết strategy dùng được cho bất kỳ kiểu dữ liệu nào.

Ví dụ:

public interface IStrategy<T>
{
    void Execute(T ctx);
}

// Một số chiến lược mẫu
public class PrintStrategy : IStrategy<string>
{
    public void Execute(string ctx) => Console.WriteLine($"In: {ctx}");
}

public class DoubleStrategy : IStrategy<int>
{
    public void Execute(int ctx) => Console.WriteLine($"Gấp đôi: {ctx * 2}");
}

// Context sử dụng Strategy
public class Context<T>
{
    private IStrategy<T> _strategy;
    public void SetStrategy(IStrategy<T> strategy) => _strategy = strategy;
    public void DoWork(T ctx) => _strategy.Execute(ctx);
}

class Program
{
    static void Main()
    {
        var stringContext = new Context<string>();
        stringContext.SetStrategy(new PrintStrategy());
        stringContext.DoWork("Xin chào!");

        var intContext = new Context<int>();
        intContext.SetStrategy(new DoubleStrategy());
        intContext.DoWork(21);
    }
}

Điểm mạnh:

  • Bạn có thể “hoán đổi” các thuật toán mà không phải thay đổi code context chính.
  • Dễ mở rộng: chỉ cần implement interface IStrategy<T> cho kiểu mới là xong.
  • Không lặp lại code: Không cần viết 10 class Repository, Factory, Singleton, Strategy cho 10 kiểu dữ liệu khác nhau!
  • An toàn kiểu: Bắt lỗi ngay khi truyền sai type, không cần ép kiểu thủ công, không sợ runtime error.
  • Dễ bảo trì: Thay đổi logic hay mở rộng cực kỳ nhanh – chỉ cần implement thêm, không đụng code cũ.
  • Tăng khả năng tái sử dụng: Một lần viết, dùng cả đời, cho mọi loại entity/service/module.

Tạo một class generic Box<T> chứa một thuộc tính Content.

  • Viết method để in ra giá trị Content.
  • Tạo hai instance: Box<int>, Box<string>, kiểm tra hoạt động.

Viết generic method FindMax<T>(IEnumerable<T> items) trả về phần tử lớn nhất, chỉ cho phép sử dụng với kiểu implement IComparable<T>.

  • Gợi ý: Dùng constraint where T : IComparable<T>

Tạo Dictionary<string, List<T>>, lưu danh sách học sinh theo lớp học. Viết code để thêm học sinh, lấy danh sách học sinh từng lớp, kiểm tra sự linh hoạt của Generics.

Đề bài:

  • Tạo class generic Box<T> có thuộc tính Content.
  • Viết method để in giá trị Content.
  • Tạo hai instance: Box<int>, Box<string>, kiểm tra hoạt động.

Lời giải:

using System;

public class Box<T>
{
    public T Content { get; set; }

    public Box(T content)
    {
        Content = content;
    }

    public void PrintContent()
    {
        Console.WriteLine($"Content: {Content}");
    }
}

class Program
{
    static void Main()
    {
        // Instance với int
        Box<int> intBox = new Box<int>(123);
        intBox.PrintContent(); // Output: Content: 123

        // Instance với string
        Box<string> strBox = new Box<string>("Xin chào Generics!");
        strBox.PrintContent(); // Output: Content: Xin chào Generics!
    }
}

Đề bài:

  • Viết method FindMax<T>(IEnumerable<T> items) trả về phần tử lớn nhất.
  • Chỉ cho phép kiểu implement IComparable<T>.

Lời giải:

using System;
using System.Collections.Generic;

public class Utils
{
    public static T FindMax<T>(IEnumerable<T> items) where T : IComparable<T>
    {
        if (items == null) throw new ArgumentNullException(nameof(items));

        using (var enumerator = items.GetEnumerator())
        {
            if (!enumerator.MoveNext())
                throw new ArgumentException("Collection is empty");

            T max = enumerator.Current;
            while (enumerator.MoveNext())
            {
                if (enumerator.Current.CompareTo(max) > 0)
                    max = enumerator.Current;
            }
            return max;
        }
    }
}

class Program
{
    static void Main()
    {
        var intList = new List<int> { 1, 5, 3, 9, 4 };
        var maxInt = Utils.FindMax(intList);
        Console.WriteLine($"Max int: {maxInt}"); // Output: Max int: 9

        var strList = new List<string> { "apple", "banana", "orange" };
        var maxStr = Utils.FindMax(strList);
        Console.WriteLine($"Max string: {maxStr}"); // Output: Max string: orange
    }
}

Đề bài:

  • Tạo Dictionary<string, List<T>> lưu danh sách học sinh theo lớp.
  • Viết code để thêm học sinh, lấy danh sách từng lớp.
  • Kiểm tra sự linh hoạt của Generics.

Lời giải:

using System;
using System.Collections.Generic;

public class ClassManager<T>
{
    // Key: tên lớp, Value: danh sách học sinh kiểu T
    private Dictionary<string, List<T>> classDict = new Dictionary<string, List<T>>();

    // Thêm học sinh vào lớp
    public void AddStudent(string className, T student)
    {
        if (!classDict.ContainsKey(className))
            classDict[className] = new List<T>();
        classDict[className].Add(student);
    }

    // Lấy danh sách học sinh của một lớp
    public List<T> GetStudents(string className)
    {
        if (classDict.TryGetValue(className, out var students))
            return students;
        return new List<T>();
    }

    // In danh sách toàn bộ lớp và học sinh
    public void PrintAll()
    {
        foreach (var kvp in classDict)
        {
            Console.WriteLine($"Lớp: {kvp.Key}");
            foreach (var student in kvp.Value)
            {
                Console.WriteLine($"  - {student}");
            }
        }
    }
}

class Program
{
    static void Main()
    {
        var manager = new ClassManager<string>();
        manager.AddStudent("10A1", "Nguyễn Văn A");
        manager.AddStudent("10A1", "Trần Thị B");
        manager.AddStudent("10A2", "Lê Văn C");

        Console.WriteLine("Danh sách lớp 10A1:");
        foreach (var student in manager.GetStudents("10A1"))
            Console.WriteLine(student);

        Console.WriteLine("\nTất cả danh sách lớp:");
        manager.PrintAll();

        // Thử với kiểu dữ liệu khác (ví dụ struct Student)
        var manager2 = new ClassManager<Student>();
        manager2.AddStudent("11B1", new Student { Name = "Hoàng", Age = 17 });
        manager2.AddStudent("11B1", new Student { Name = "Minh", Age = 16 });
        manager2.PrintAll();
    }
}

public struct Student
{
    public string Name;
    public int Age;

    public override string ToString() => $"{Name} ({Age} tuổi)";
}

  • Generic giúp code C# của bạn tái sử dụng dễ dàng, an toàn kiểu dữ liệu, và hiệu suất tốt hơn.
  • Kết hợp Generic với các design pattern (Repository, Factory, Strategy) sẽ giúp hệ thống mềm dẻo, mở rộng tốt.
  • Hiểu và áp dụng constraints, covariance/contravariance sẽ giúp bạn làm chủ được Generic ở cấp độ nâng cao.
  • Đừng quên luyện tập nhiều để làm quen với cách tư duy trừu tượng (abstract thinking) khi sử dụng Generic!

Nội Dung Liên Quan