Hiểu về Covariance, Contravariance và Invariance trong Lập Trình

Covariance, Contravariance và Invariance là gì?

Trong lập trình hướng đối tượng và kiểu dữ liệu, ba khái niệm này định nghĩa cách các kiểu dữ liệu liên quan đến nhau (ví dụ, lớp cha - lớp con) có thể được thay thế cho nhau trong các ngữ cảnh khác nhau, như mảng, kế thừa, và đặc biệt là generic.

  • Covariance (Hiệp biến): Cho phép bạn sử dụng một kiểu con (subtype) ở nơi mà kiểu cha (supertype) được mong đợi. Ví dụ, nếu bạn có một hàm nhận đầu vào là Shape, bạn có thể truyền vào một đối tượng Circle (nếu Circle là con của Shape).
  • Contravariance (Phản biến): Cho phép bạn sử dụng một kiểu cha ở nơi mà kiểu con được mong đợi. Ví dụ, nếu bạn có một hàm cần một Comparison<Circle>, bạn có thể dùng Comparison<Shape>.
  • Invariance (Bất biến): Không cho phép sự thay thế nào ở trên. Kiểu phải khớp chính xác.

Covariance và Contravariance với Generics (C#, Java)

Trong C#, các tham số generic mặc định là bất biến. Tuy nhiên, bạn có thể đánh dấu chúng là hiệp biến (out) hoặc phản biến (in) khi khai báo interface hoặc delegate.

  • Covariance (out): Bạn có thể dùng IEnumerable<string> ở nơi cần IEnumerable<object>. Tham số generic được đánh dấu out vì nó thường ở vị trí "đầu ra" (output) – chỉ đọc (read-only).
  • Contravariance (in): Bạn có thể dùng IComparable<object> ở nơi cần IComparable<string>. Tham số generic được đánh dấu in vì nó thường ở vị trí "đầu vào" (input) – chỉ ghi (write).
// Ví dụ Covariance trong C#
public interface IEnumerator<out T>
{
    T Current { get; } // Vị trí đầu ra (output)
    bool MoveNext();
}

IEnumerator<string> strEnum = new Enumerator<string>();
IEnumerator<object> objEnum = strEnum; // OK: Covariance

// Ví dụ Contravariance trong C#
public interface IComparer<in T>
{
    int Compare(T a, T b); // Vị trí đầu vào (input)
}

IComparer<object> objComp = new Comparer<object>();
IComparer<string> strComp = objComp; // OK: Contravariance

Trong Java, tham số generic cũng bất biến. Bạn có thể sử dụng ký tự đại diện (wildcard) với extendssuper để đạt được hiệu ứng tương tự tại điểm sử dụng.

  • List<? extends T>covariant (dùng cho Producer – chỉ đọc). T là giới hạn trên.
  • List<? super T>contravariant (dùng cho Consumer – chỉ ghi). T là giới hạn dưới.

Tính An Toàn Kiểu Dữ Liệu

  • Covariance an toàn: Khi bạn đọc dữ liệu từ một vị trí out, bạn đảm bảo lấy ra được kiểu con hoặc kiểu cha. Việc thay thế kiểu con bằng kiểu cha là an toàn vì kiểu con chắc chắn có thể được xem như kiểu cha.
  • Contravariance an toàn: Khi bạn ghi dữ liệu (thêm vào) một vị trí in, bạn đảm bảo kiểu dữ liệu bạn đưa vào có thể được xử lý. Việc thay thế kiểu cha bằng kiểu con là an toàn vì kiểu cha có thể nhận kiểu con.
// Ví dụ áp dụng cả hai trong Java
public class Collections {
    public static <T> void copy(List<? super T> dest, List<? extends T> src) {
        for (int i = 0; i < src.size(); i++) {
            dest.set(i, src.get(i)); // src là Producer (read), dest là Consumer (write)
        }
    }
}

Covariance với Mảng (C#, Java)

Trong C# và Java, mảng là covariant. Điều này có nghĩa là string[] là một kiểu con của object[].

  • Ví dụ: string[] có thể được gán cho biến object[].
  • Không an toàn: Tính năng này có thể gây ra lỗi runtime (ví dụ: gán một Integer vào một mảng String[]). Lý do lịch sử là Java và C# không có generics từ đầu, và covariance giúp tận dụng được tính đa hình (polymorphism) với mảng.

Covariance, Contravariance và Kế Thừa (C++, Java)

Một số ngôn ngữ như C++ và Java hỗ trợ covariant return type. Điều này cho phép một phương thức ở lớp con ghi đè (override) phương thức ở lớp cha với kiểu trả về là kiểu con của kiểu trả về ban đầu. C# không hỗ trợ tính năng này.

class VehicleFactory {
public:
    virtual Vehicle* create() const { return new Vehicle(); }
    virtual ~VehicleFactory() {}
};

class CarFactory : public VehicleFactory {
public:
    // Covariant return type: Car* là kiểu con của Vehicle*
    virtual Car* create() const override { return new Car(); }
};

Ngược lại, hầu hết các ngôn ngữ chính thống (C++, C#, Java) không hỗ trợ contravariant hoặc covariant argument types trong kế thừa để đảm bảo an toàn kiểu.

Mối Quan Hệ và Quy Tắc Tổng Quát

Nếu S là kiểu con của T (ký hiệu S <: T):

  • Covariant trong ngữ cảnh F: Quan hệ con-cha được giữ nguyên. F(S) <: F(T)
  • Contravariant trong ngữ cảnh F: Quan hệ bị đảo ngược. F(T) <: F(S)
  • Invariant: Không có quan hệ nào.

Một cách dễ nhớ cho kiểu hàm (A -> B):

  • Tham số (Input) là Contravariant: Nếu bạn có hàm nhận Shape, bạn có thể dùng nó ở nơi cần hàm nhận Circle.
  • Giá trị trả về (Output) là Covariant: Nếu bạn có hàm trả về Circle, bạn có thể dùng nó ở nơi cần hàm trả về Shape.

Covariance, Contravariance trong C++

Trong C++, con trỏ và tham chiếu tự nhiên là covariant (con trỏ lớp con có thể được dùng thay cho con trỏ lớp cha).

Đối với templates (khuôn mẫu):

  • Bất biến theo mặc định.
  • std::shared_ptrstd::unique_ptr hỗ trợ covariance (giống con trỏ thô).
  • std::function hỗ trợ cả covariance (cho kiểu trả về) và contravariance (cho kiểu tham số).
#include <memory>
#include <functional>

struct Base {};
struct Derived : Base {};

int main() {
    std::shared_ptr<Derived> p;
    std::shared_ptr<Base> p2 = p; // Covariance của shared_ptr

    std::function<Derived*(Base*)> f;
    std::function<Base*(Derived*)> f2 = f; // Covariance (trả về), Contravariance (tham số)
}

Thẻ: C# Java C++ Generic covariance

Đăng vào ngày 14 tháng 6 lúc 16:00