연산자 오버로딩
내가 만든 형식에도 + 와 == 를 사용하게 합니다.
내가 만든 형식에도 + 를 붙이기
int 두 개는 a + b 로 더합니다.
그런데 2.7에서 만든
Point 두 개를 같은 식으로 더하면 컴파일이 되지 않습니다.
var a = new Point(3, 4); var b = new Point(1, 2); Console.WriteLine(a + b); // CS0019: '+' 연산자는 'Point' 및 'Point' 형식의 피연산자에 적용할 수 없습니다
+ 가 Point 에게 무슨 뜻인지
컴파일러가 알 방법이 없기 때문입니다. 연산자 오버로딩은 그 뜻을
직접 정해 주는 문법입니다. 정하고 나면 Point 도
int 처럼 + 로 더할 수 있습니다.
다만 뜻이 분명할 때만 정하세요. 좌표를 더하거나 금액을 빼는 것은
누구나 같은 결과를 떠올리지만, 주문에 + 를 붙여 놓으면
읽는 사람이 무슨 일이 일어나는지 알 수 없습니다. 그럴 때는 이름이 있는 메서드가
낫습니다.
Point 에 + 와 - 정해 주기
아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.
var a = new Point(3, 4); var b = new Point(1, 2); Console.WriteLine(a + b); Console.WriteLine(a - b); var c = a; c += b; // += 는 따로 정하지 않아도 됩니다 Console.WriteLine(c); readonly struct Point(int x, int y) { public int X { get; } = x; public int Y { get; } = y; public static Point operator +(Point l, Point r) => new Point(l.X + r.X, l.Y + r.Y); public static Point operator -(Point l, Point r) => new Point(l.X - r.X, l.Y - r.Y); public override string ToString() => $"({X}, {Y})"; }
코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.
operator 뒤에 기호를 적는 것만 낯설 뿐, 나머지는 그냥
메서드입니다. 매개 변수 둘을 받아 결과를 돌려주는 정적 메서드에
이름 대신 기호가 붙었다고 보면 됩니다.
메서드로 둘 때와 무엇이 다른가
public Point Add(Point r) => new Point(X + r.X, Y + r.Y); // 셋을 더할 때 var sum = a.Add(b).Add(c); // 더하고 빼면 var r = a.Add(b).Subtract(c);
public static Point operator +(Point l, Point r) => new Point(l.X + r.X, l.Y + r.Y); // 셋을 더할 때 var sum = a + b + c; // 더하고 빼면 var r = a + b - c;
오른쪽이 계산의 모양 그대로 읽힙니다. 대신 기호는 이름만큼 설명해 주지 못하므로, 앞에서 말한 대로 뜻이 분명한 것에만 붙입니다.
지켜야 할 규칙 셋
연산자를 정할 때 컴파일러가 강제하는 것이 셋 있습니다. 어기면 실행 전에 오류로 드러나므로 외우기보다 오류 번호를 기억해 두는 편이 낫습니다.
-
public static이어야 합니다. 인스턴스 메서드로 두면CS0558입니다. 연산자는 특정 객체가 아니라 형식에 귀속됩니다. -
매개 변수 중 하나는 그 형식이어야 합니다.
int둘을 받는+를 남의 형식 안에 정해 둘 수는 없습니다 —CS0563입니다. -
짝이 있는 것은 함께 정합니다.
==와!=,<와>,<=와>=는 한쪽만 두면CS0216입니다.
반대로 공짜로 따라오는 것도 있습니다. + 를
정하면 += 는 저절로 됩니다. 복합 대입은
c = c + b 로 풀어서 처리되기 때문입니다. 위 예제의
c += b 가 그렇게 동작한 것입니다.
== 는 특히 조심해야 합니다
== 는 정하지 않았을 때의 동작이 클래스·구조체·record
마다 다릅니다. 그중 하나는 오류도 없이 틀린 답을 냅니다.
// 구조체 — 컴파일 오류라 바로 알 수 있습니다 var s1 = new S { V = 1 }; var s2 = new S { V = 1 }; Console.WriteLine(s1 == s2); // CS0019 — 적용할 수 없습니다 // 클래스 — 컴파일되고 False 가 나옵니다. 값이 같은데도요 var c1 = new K { V = 1 }; var c2 = new K { V = 1 }; Console.WriteLine(c1 == c2); // False — 값이 아니라 같은 객체인지를 봅니다 // record — 값 비교를 저절로 얻습니다 Console.WriteLine(new R(1) == new R(1)); // True struct S { public int V; } class K { public int V; } record R(int V);
클래스의 기본 == 는 참조 비교입니다.
2.1에서 본 대로 클래스는 객체가 있는
자리를 가리키므로, 담긴 값이 같아도 서로 다른 자리에 있으면
False 입니다. 값으로 비교하고 싶다면 직접 정해야 합니다.
그리고 == 를 정하면 컴파일러가 경고를 둘 냅니다 —
CS0660·CS0661 입니다.
Equals 와 GetHashCode 도 함께
맞추라는 뜻입니다. Dictionary 나
HashSet 은 == 가 아니라
GetHashCode 와 Equals 를
보고 같은 것인지 판단하기 때문에, 한쪽만 맞추면 같은 값을 두 번 담게 됩니다.
public static bool operator ==(Point l, Point r) => l.X == r.X && l.Y == r.Y; public static bool operator !=(Point l, Point r) => !(l == r); public override bool Equals(object? o) => o is Point p && this == p; public override int GetHashCode() => HashCode.Combine(X, Y);
값을 담기만 하는 형식이라면 이 네 개를 직접 적기보다
2.7에서 본
record 를 사용하는 편이 낫습니다. 전부 만들어 줍니다.
형식을 바꿔 주는 연산자
+ 같은 것 말고 형식 변환 자체를 정할 수도
있습니다. implicit 은 그냥 대입해도 바뀌고,
explicit 은 (형식) 을 앞에
적어야 바뀝니다 — 1.4에서 본
캐스트가 여기서 다시 나옵니다.
public static implicit operator Point(int v) => new Point(v, v); public static explicit operator int(Point p) => p.X; // 사용하는 쪽 Point p = 5; // (5, 5) — implicit 이라 그냥 대입됩니다 int n = (int)a; // 3 — explicit 이라 캐스트를 적어야 합니다
선택하는 기준은 값을 잃는가입니다. 잃는 것 없이 바뀐다면
implicit, 잘리거나 예외가 날 수 있다면
explicit 입니다. 위의
(int) 는 Y 를 버리므로
explicit 이 맞습니다. 읽는 사람이 캐스트를 보고
무언가 잃는다는 것을 알아차리게 하는 표시입니다.
정할 수 없는 것
모든 기호를 정할 수 있는 것은 아닙니다. =(대입),
&&·||,
?:, .,
new 는 직접 정할 수 없습니다. 언어의 기본 구조에 해당하는
것이라 뜻이 바뀌면 코드를 읽을 수 없게 되기 때문입니다.
인터페이스에 연산자 두기
2.6에서 본 인터페이스 멤버는 모두
객체 하나하나에 대한 약속이었습니다. 구현하는 쪽이 객체를 만들어야
그 멤버를 부를 수 있었습니다. C# 11.0의 static abstract 는
요구 대상을 형식 자체로 바꿉니다 — 객체가 아니라
int 나 Point 라는
형식이 무엇을 갖추어야 하는지를 적는 것입니다.
interface IAnimal { static abstract string Kind { get; } // 객체가 아니라 형식이 가집니다 } class Dog : IAnimal { public static string Kind => "개"; // static 으로 채웁니다 }
연산자는 앞에서 본 대로 반드시 public static 이므로,
이 문법이 생기고 나서야 인터페이스에 연산자를 요구할 수 있게
되었습니다. 그리고 이것이 필요한 곳은 형식이 무엇인지 모르는
자리입니다.
using System.Numerics; // INumber<T> 가 +, -, Zero 를 요구하므로 T 를 몰라도 더할 수 있습니다 static T Sum<T>(T[] items) where T : INumber<T> { T total = T.Zero; foreach (var it in items) total += it; return total; } Console.WriteLine(Sum([1, 2, 3])); // 6 Console.WriteLine(Sum([1.5, 2.5])); // 4
T.Zero 를 부를 수 있는 것은 숫자라서가
아닙니다. INumber<T> 가 물려받는
INumberBase<T> 에
static abstract T Zero { get; } 가 선언되어 있기
때문입니다. 인터페이스가 정적 멤버를 요구해 두었으므로 형식 매개 변수에
점을 찍어 꺼낼 수 있는 것입니다. 제약을
IAdditionOperators<T, T, T> 로 줄이면
+ 는 그대로 되지만
T.Zero 는 CS0704 로
막힙니다 — 아무도 약속한 적이 없기 때문입니다.
int 든 double 든
decimal 이든 같은 메서드 하나로
처리됩니다. 형식마다 Sum 을 따로 적을 필요가 없어진
것입니다. 제네릭은 3부에서 자세히 다룹니다.
이 문법, 몇 버전부터 사용할 수 있나요
- 1.0연산자 오버로딩 · implicit · explicit 변환
- 9.0record — == 와 Equals 를 자동으로
- 11.0인터페이스의 static abstract 연산자 · INumber<T>
연산자를 정하는 문법 자체는 C# 1.0부터 있었고 지금도 그대로입니다. 달라진 것은
직접 적을 일이 줄었다는 점입니다 — C# 9.0의
record 가 == 와
Equals 를 대신 만들어 주고, C# 11.0의
static abstract 는 연산자를 인터페이스로 묶어
형식마다 같은 코드를 되풀이하지 않게 했습니다.
직접 해보기
원 단위 금액을 담는 Money 구조체를 만들고
+ 와 - 를 정해보세요.
화면에 찍었을 때 3000원 처럼 나오게
ToString 도 맞춰 봅니다.
섭씨 온도를 담는 Temperature 클래스를 만들고,
담긴 값이 같으면 같은 것으로 보도록
== 와 != 를 정해보세요.
경고가 나지 않게 Equals 와
GetHashCode 도 함께 맞춰야 합니다.
- 연산자 오버로딩은
public static메서드에 이름 대신 기호를 붙인 것입니다. - 매개 변수 하나는 그 형식이어야 하고,
==/!=처럼 짝이 있는 것은 함께 정합니다. +를 정하면+=는 따라옵니다.- 클래스의 기본
==는 참조 비교라 값이 같아도False입니다. 값으로 비교하려면 직접 정하거나record를 사용하세요. - 변환은 잃는 것이 없으면
implicit, 잘릴 수 있으면explicit입니다.
C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.