1. Giới thiệu
Chắc là bạn đã quen dùng các toán tử như + để cộng số hoặc == để so sánh. Mấy toán tử này chạy ngon với kiểu dữ liệu built-in. Nhưng nếu bạn tự tạo class riêng, ví dụ Vector (vector), và muốn cộng hai vector nhìn cho tự nhiên kiểu vector1 + vector2? Hoặc muốn so sánh hai object Money (tiền) chạy như moneyAmount1 == moneyAmount2? Đó chính là lúc C# có nạp chồng toán tử.
Nạp chồng toán tử cho phép bạn định nghĩa cách các toán tử chuẩn (+, -, *, /, ==, !=, >, <, ++, -- và nhiều cái khác) hoạt động với instance của class hoặc struct tự định nghĩa. Nhờ vậy code của bạn sẽ trực quan, dễ đọc, dễ hiểu hơn, dùng cú pháp quen thuộc cho kiểu dữ liệu custom.
Cơ bản về nạp chồng toán tử
Để nạp chồng toán tử, bạn khai báo một static method trong class hoặc struct, dùng từ khóa operator rồi tới ký hiệu toán tử muốn nạp chồng.
- Static method: Method nạp chồng toán tử luôn phải là public static.
- Tên method: Tên method là từ khóa operator rồi tới ký hiệu toán tử (ví dụ, operator +, operator ==).
- Tham số: Số tham số tùy vào loại toán tử (unary hay binary).
- Unary operator (ví dụ, +, -, !, ++, --): nhận một tham số kiểu class/struct nơi nó được định nghĩa.
- Binary operator (ví dụ, +, -, *, /, ==, !=): nhận hai tham số, ít nhất một trong số đó phải là kiểu class/struct nơi nó được định nghĩa.
- Kiểu trả về: Kiểu mà phép toán trả về.
2. Nạp chồng toán tử nhị phân (Binary Operators)
Toán tử nhị phân nhận hai toán hạng. Phổ biến nhất là toán tử số học (+, -, *, /) và toán tử so sánh (==, !=, >, <, >=, <=).
Ví dụ: Nạp chồng toán tử cộng (+)
public struct Point
{
public int X { get; set; }
public int Y { get; set; }
public Point(int x, int y) => (X, Y) = (x, y);
// Nạp chồng toán tử +
public static Point operator +(Point p1, Point p2)
{
return new Point(p1.X + p2.X, p1.Y + p2.Y);
}
public override string ToString() => $"({X}, {Y})";
}
// Sử dụng:
Point point1 = new Point(1, 2);
Point point2 = new Point(3, 4);
Point sumPoint = point1 + point2; // Gọi toán tử + đã nạp chồng
Console.WriteLine($"Tổng điểm: {sumPoint}"); // Kết quả: Tổng điểm: (4, 6)
Ở đây mình định nghĩa cộng hai object Point sẽ trả về một object Point mới, với tọa độ là tổng các tọa độ tương ứng của hai điểm gốc.
Ví dụ: Nạp chồng toán tử nhân (*) với số (scalar)
public struct Vector
{
public double X { get; set; }
public double Y { get; set; }
public Vector(double x, double y) => (X, Y) = (x, y);
// Nạp chồng toán tử * (vector nhân số)
public static Vector operator *(Vector vec, double scalar)
{
return new Vector(vec.X * scalar, vec.Y * scalar);
}
// Nạp chồng toán tử * (số nhân vector) - cho đối xứng
public static Vector operator *(double scalar, Vector vec)
{
return new Vector(vec.X * scalar, vec.Y * scalar);
}
public override string ToString() => $"<{X}, {Y}>";
}
// Sử dụng:
Vector vec1 = new Vector(2, 3);
Vector scaledVec1 = vec1 * 5; // Gọi Vector * double
Vector scaledVec2 = 5 * vec1; // Gọi double * Vector
Console.WriteLine($"Vector đã scale 1: {scaledVec1}"); // Kết quả: Vector đã scale 1: <10, 15>
Console.WriteLine($"Vector đã scale 2: {scaledVec2}"); // Kết quả: Vector đã scale 2: <10, 15>
Lưu ý, toán tử nhân được nạp chồng hai lần để nó hoạt động đối xứng: Vector * double và double * Vector.
Ví dụ: Nạp chồng toán tử so sánh (== và !=)
Khi nạp chồng toán tử == và != bạn phải tuân thủ hợp đồng quan trọng: nếu nạp chồng một cái thì phải nạp chồng cả cái còn lại. Ngoài ra, rất nên đảm bảo hành vi của các toán tử này giống với method Equals() và GetHashCode().
public class Money
{
public decimal Amount { get; set; }
public string Currency { get; set; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
// Nhớ override Equals và GetHashCode nếu nạp chồng == / !=
public override bool Equals(object? obj)
{
if (obj is not Money other) return false;
return Amount == other.Amount && Currency == other.Currency;
}
public override int GetHashCode() => HashCode.Combine(Amount, Currency);
// Nạp chồng toán tử ==
public static bool operator ==(Money? m1, Money? m2)
{
// Kiểm tra null cho reference type
if (ReferenceEquals(m1, null)) return ReferenceEquals(m2, null);
return m1.Equals(m2); // Dùng Equals đã override
}
// Nạp chồng toán tử != (bắt buộc khi nạp chồng ==)
public static bool operator !=(Money? m1, Money? m2)
{
return !(m1 == m2);
}
}
// Sử dụng:
Money cash1 = new Money(100, "USD");
Money cash2 = new Money(100, "USD");
Money cash3 = new Money(50, "USD");
Money cash4 = new Money(100, "EUR");
Console.WriteLine($"cash1 == cash2: {cash1 == cash2}"); // True
Console.WriteLine($"cash1 == cash3: {cash1 == cash3}"); // False
Console.WriteLine($"cash1 == cash4: {cash1 == cash4}"); // False
Với kiểu giá trị (struct), toán tử == và != mặc định so sánh bit, thường là đúng. Nhưng với class (class) mặc định == so sánh reference, nên nạp chồng nó rất hữu ích để so sánh theo giá trị.
3. Nạp chồng toán tử một ngôi (Unary Operators)
Toán tử một ngôi làm việc với một toán hạng. Ví dụ: + (dấu cộng một ngôi), - (dấu trừ một ngôi), ! (NOT logic), ~ (NOT bit), ++ (tăng), -- (giảm).
Ví dụ: Nạp chồng toán tử trừ một ngôi (-)
public struct Vector3D
{
public double X, Y, Z;
public Vector3D(double x, double y, double z) => (X, Y, Z) = (x, y, z);
// Nạp chồng toán tử - một ngôi
public static Vector3D operator -(Vector3D vec)
{
return new Vector3D(-vec.X, -vec.Y, -vec.Z);
}
public override string ToString() => $"<{X}, {Y}, {Z}>";
}
// Sử dụng:
Vector3D originalVec = new Vector3D(1, -2, 3);
Vector3D invertedVec = -originalVec; // Gọi toán tử - một ngôi đã nạp chồng
Console.WriteLine($"Vector gốc: {originalVec}"); // Kết quả: Vector gốc: <1, -2, 3>
Console.WriteLine($"Vector đảo dấu: {invertedVec}"); // Kết quả: Vector đảo dấu: <-1, 2, -3>
Ví dụ: Nạp chồng toán tử tăng (++) và giảm (--)
Hai toán tử này thay đổi toán hạng và trả về giá trị đã thay đổi.
public struct Counter
{
public int Value { get; set; }
public Counter(int value) => Value = value;
// Nạp chồng toán tử tăng ++
public static Counter operator ++(Counter c)
{
// Lưu ý: trả về OBJECT MỚI nếu struct là immutable,
// hoặc thay đổi object hiện tại nếu là class
return new Counter(c.Value + 1);
}
// Nạp chồng toán tử giảm --
public static Counter operator --(Counter c)
{
return new Counter(c.Value - 1);
}
public override string ToString() => $"[Bộ đếm: {Value}]";
}
// Sử dụng:
Counter myCounter = new Counter(5);
myCounter++; // Tăng hậu tố
Console.WriteLine(myCounter); // Kết quả: [Bộ đếm: 6]
++myCounter; // Tăng tiền tố
Console.WriteLine(myCounter); // Kết quả: [Bộ đếm: 7]
myCounter--;
Console.WriteLine(myCounter); // Kết quả: [Bộ đếm: 6]
Toán tử ++ và -- nạp chồng luôn là tiền tố (tăng/giảm rồi trả về). Compiler sẽ tự sinh hành vi đúng cho dạng hậu tố.
4. Nạp chồng toán tử chuyển đổi kiểu
Bạn có thể định nghĩa cách instance của kiểu bạn tự tạo được chuyển đổi tường minh hoặc ngầm định sang kiểu khác, và ngược lại.
- implicit (chuyển đổi ngầm định): Dùng khi chuyển đổi luôn an toàn, không mất dữ liệu (ví dụ, int sang long).
- explicit (chuyển đổi tường minh): Dùng khi chuyển đổi có thể mất dữ liệu hoặc lỗi, cần chỉ rõ ((Type)obj).
Ví dụ: Chuyển đổi ngầm định Score sang int
public struct Score
{
public int Points { get; set; }
public Score(int points) => Points = points;
// Chuyển đổi ngầm định Score sang int
public static implicit operator int(Score s)
{
return s.Points;
}
}
// Sử dụng:
Score examScore = new Score(95);
int scoreValue = examScore; // Chuyển đổi ngầm định
Console.WriteLine($"Điểm: {scoreValue}"); // Kết quả: Điểm: 95
Ví dụ: Chuyển đổi tường minh Celsius sang Fahrenheit
public struct Celsius
{
public double Degrees { get; set; }
public Celsius(double degrees) => Degrees = degrees;
}
public struct Fahrenheit
{
public double Degrees { get; set; }
public Fahrenheit(double degrees) => Degrees = degrees;
// Chuyển đổi tường minh Fahrenheit sang Celsius
public static explicit operator Celsius(Fahrenheit f)
{
return new Celsius((f.Degrees - 32) * 5 / 9);
}
// Chuyển đổi tường minh Celsius sang Fahrenheit
public static explicit operator Fahrenheit(Celsius c)
{
return new Fahrenheit(c.Degrees * 9 / 5 + 32);
}
}
// Sử dụng:
Celsius c = new Celsius(25);
Fahrenheit f = (Fahrenheit)c; // Chuyển đổi tường minh
Console.WriteLine($"25°C = {f.Degrees}°F"); // Kết quả: 25°C = 77°F
Fahrenheit f2 = new Fahrenheit(212);
Celsius c2 = (Celsius)f2; // Chuyển đổi tường minh
Console.WriteLine($"212°F = {c2.Degrees}°C"); // Kết quả: 212°F = 100°C
5. Giới hạn và khuyến nghị
Không phải toán tử nào cũng nạp chồng được: Bạn không thể nạp chồng các toán tử như &&, ||, ?., new, typeof, is, as, == (cho string vì nó đã được nạp chồng đặc biệt trong class string), () (gọi method), = (gán) và một số cái khác.
Tính đối xứng: Nếu bạn nạp chồng toán tử nhị phân, thường nên nạp chồng cả cho thứ tự toán hạng đảo ngược nếu hợp lý (như ví dụ Vector * double).
Hợp đồng: Luôn tuân thủ hợp đồng, nhất là với toán tử so sánh (==, !=, >, <, >=, <=) và liên hệ với Equals() và GetHashCode(). Vi phạm hợp đồng có thể gây bug khó lường, nhất là khi dùng trong collection.
Dễ đọc và trực quan: Chỉ nạp chồng toán tử khi nó làm code dễ hiểu và trực quan hơn. Nếu hành vi toán tử không rõ ràng hoặc dễ gây hiểu nhầm, nên dùng method có tên rõ ràng (ví dụ, Add() thay vì +).
Object mutable: Cẩn thận khi nạp chồng toán tử cho kiểu mutable. Ví dụ, Vector v1 = v2 + v3; mặc định sẽ tạo object mới, không thay đổi v2. Nếu toán tử của bạn thay đổi object hiện tại, có thể gây khó hiểu.
Struct vs Class: Nạp chồng toán tử thường dùng cho struct, vì bản chất nó thường là kiểu giá trị (ví dụ, Point, ComplexNumber), và các phép toán số học hoặc so sánh giá trị là tự nhiên.
GO TO FULL VERSION