반응형

[PART3.연산자와 표현식(10/11)] 사용자 정의 복합 할당 연산자 — +=·-= 를 직접 정의 (C# 14)

왜 C# 13까지는 += 가 항상 새 객체를 만들었는가 / C# 14 void operator += 로 in-place 수정을 돌려받는 방법 / Unity 벡터·컨테이너 연산에서의 GC 효과


1. 문제 제기 — 매 프레임 벡터 덧셈이 힙에 남기는 흔적

Unity 에서 가장 흔한 코드 한 줄을 떠올려 봅시다.

C#
void Update()
{
    velocity += gravity * Time.deltaTime;   // (1)
    position += velocity * Time.deltaTime;  // (2)
}

초당 60~144 프레임, 씬 안의 모든 MonoBehaviour 가 이런 연산을 여러 번 수행합니다. "그냥 더하는 건데 뭐가 문제인가" 싶지만, C# 13 까지는 +=Vector3 를 만들어 원래 변수에 복사해 넣는 구조로 돌아갑니다. 즉 매 프레임 수백 번의 임시 값 타입 복사가 발생합니다.

값 타입이면 힙 할당은 없으니 GC 와 무관한 것 아니냐고 생각할 수 있지만, 커스텀 컨테이너·큰 구조체로 시야를 넓히면 그림이 달라집니다. 업계에서 공개된 한 시뮬레이션은 같은 로직을 C# 13 방식으로 돌렸을 때 힙에 134 개의 임시 객체가 쌓였지만, C# 14 의 사용자 정의 += 를 사용하자 10 개로 줄었다고 보고했습니다. 객체 할당이 92 % 감소한 것이죠.

이 차이가 어디서 왔는지, 왜 C# 13까지는 그 차이를 만들 수 없었는지, C# 14 가 어떤 문법을 새로 열어 주었는지를 이 글에서 따라가 봅니다.


2. 개념 정의 — += 는 원래 "새 값을 만들어 바꿔치기"였다

2.1 비유 — 메모지와 화이트보드

+= 를 두 가지 방식으로 구현할 수 있습니다.

  • 메모지 방식 (C# 13까지): 기존 메모지를 버리고, 새 메모지에 "기존 값 + 추가 값" 을 적은 뒤, 원래 자리에 새 메모지를 둡니다. 메모지가 매번 하나씩 소모됩니다.
  • 화이트보드 방식 (C# 14 신규): 같은 화이트보드 위에 새로 계산한 값으로 덮어 씁니다. 화이트보드는 하나뿐이고, 메모지는 쓰이지 않습니다.

C# 13 까지는 이 "메모지 방식" 만 허용되었고, C# 14 부터 "화이트보드 방식" 을 직접 선택할 수 있게 되었습니다.

2.2 시각화 — 두 방식의 메모리 흐름

C# 13까지: a = a + b (새 인스턴스 생성)

2.3 기본 C# 코드 — C# 13 방식 (자동 파생)

C#
// C# 13 까지: operator + 만 정의하면 += 는 자동으로 제공됨
public struct Vec13
{
    public float X, Y, Z;
    public Vec13(float x, float y, float z) { X = x; Y = y; Z = z; }

    public static Vec13 operator +(Vec13 a, Vec13 b)
        => new Vec13(a.X + b.X, a.Y + b.Y, a.Z + b.Z);
}

var v = new Vec13(1, 2, 3);
var d = new Vec13(0.1f, 0.2f, 0.3f);
v += d;  // 컴파일러가 v = v + d 로 확장
operator + — 이항 산술 연산자 오버로딩 사용자 정의 타입에 + 를 적용할 때 호출되는 static 메서드다. 반환 타입은 보통 타입 자신이며, 새 인스턴스를 만들어 돌려준다.
예시: public static Vec operator +(Vec a, Vec b) => new(a.X+b.X, a.Y+b.Y, a.Z+b.Z);Vec 을 받아 새 Vec 을 반환

2.4 기본 C# 코드 — C# 14 방식 (직접 정의)

C#
// C# 14: += 를 별도로, 인스턴스 메서드로 직접 정의
public struct Vec14
{
    public float X, Y, Z;
    public Vec14(float x, float y, float z) { X = x; Y = y; Z = z; }

    public static Vec14 operator +(Vec14 a, Vec14 b)
        => new Vec14(a.X + b.X, a.Y + b.Y, a.Z + b.Z);

    // static 없음, 반환 타입 void — 인스턴스 자신을 직접 수정
    public void operator +=(Vec14 other)
    {
        X += other.X;
        Y += other.Y;
        Z += other.Z;
    }
}

var v = new Vec14(1, 2, 3);
var d = new Vec14(0.1f, 0.2f, 0.3f);
v += d;  // 컴파일러가 v.op_AdditionAssignment(d) 로 바로 디스패치
void operator += — 사용자 정의 복합 할당 연산자 (User-defined compound assignment operator, C# 14) static 이 아닌 인스턴스 메서드로 선언하고 반환 타입을 void 로 지정한다. this 를 직접 수정하는 방식으로 += 의 의미를 재정의한다. a = a + b 로 확장되지 않는다.
예시: public void operator +=(Vec other) { X += other.X; Y += other.Y; } this 의 필드를 그 자리에서 갱신

2.5 쉬운 설명

  • C# 13까지: += 는 "덧셈 결과로 변수를 바꿔치기" 다.
  • C# 14 부터: += 를 "내 상태를 바로 갱신하는 연산" 으로 바꿀 수 있다.
  • 바뀌는 건 의미 가 아니라 구현 전략 이다. 외부에서 보는 결과 값은 같아야 한다.

2.6 기술 정의

사용자 정의 복합 할당 연산자는 컴파일러가 a += b 등의 복합 할당 표현식을 만났을 때, 타입에 직접 정의된 op_AdditionAssignment (및 같은 계열) 인스턴스 메서드를 호출하도록 지시하는 C# 14 의 새 선언 형태입니다. 반환 타입은 반드시 void 이며, 메서드는 this 를 변경해 결과를 내보냅니다. 해당 메서드가 정의되어 있지 않으면 컴파일러는 기존 규칙대로 a = a + b 로 확장해 처리합니다.


3. 내부 동작 — IL 이 스택 위에서 무엇을 하는가

3.1 IL 차이의 한눈 비교

C# 13: v += d (op_Addition 후 stloc)

3.2 실제 컴파일 결과 — C# 13 방식

다음 C# 을 .NET 10 · LangVersion=preview 로 컴파일해 ilspycmd 로 디스어셈블한 결과입니다.

C#
public static void UseVec13()
{
    var v = new Vec13(1, 2, 3);
    var d = new Vec13(0.1f, 0.2f, 0.3f);
    v += d;
}
IL
.method public hidebysig static void UseVec13 () cil managed
{
    .locals init (
        [0] valuetype Vec13
    )

    // v = new Vec13(1, 2, 3)
    IL_0000: ldc.r4 1
    IL_0005: ldc.r4 2
    IL_000a: ldc.r4 3
    IL_000f: newobj instance void Vec13::.ctor(float32, float32, float32)
    IL_0014: ldloca.s 0
    // d = new Vec13(0.1, 0.2, 0.3) — 그 위에 이어짐
    IL_0016: ldc.r4 0.1
    IL_001b: ldc.r4 0.2
    IL_0020: ldc.r4 0.3
    IL_0025: call instance void Vec13::.ctor(float32, float32, float32)

    // v + d 그리고 v 에 재저장
    IL_002a: ldloc.0   // v 를 스택에 복사
    IL_002b: call valuetype Vec13 Vec13::op_Addition(valuetype Vec13, valuetype Vec13)
    IL_0030: pop       // (예시에서는 결과를 저장하지 않고 버리는 호출 지점)
    IL_0031: ret
}

핵심 관찰:

  • op_Additionstatic 이며 값 두 개를 받아 값 하나를 반환 합니다.
  • 호출 사이트에서 결과를 다시 로컬에 되돌리려면 stloc 이 한 번 더 필요합니다. (위 예시는 결과를 사용하지 않아 pop 으로 대체됨.)
  • += 한 번마다 임시 Vec13 이 IL 스택에 만들어집니다.

3.3 실제 컴파일 결과 — C# 14 방식

같은 로직을 C# 14 방식으로 선언하고 v += d 를 한 IL.

IL
.method public hidebysig specialname
    instance void op_AdditionAssignment (valuetype Vec14 other) cil managed
{
    .custom instance void System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute::.ctor(string)
        = ( "UserDefinedCompoundAssignmentOperators" )

    // X += other.X
    IL_0000: ldarg.0                       // this 포인터
    IL_0001: ldarg.0
    IL_0002: ldfld float32 Vec14::X
    IL_0007: ldarg.1
    IL_0008: ldfld float32 Vec14::X
    IL_000d: add
    IL_000e: stfld float32 Vec14::X        // this.X 에 바로 기록

    // Y, Z 도 같은 패턴 (생략)
    IL_0039: ret
}

.method public hidebysig static void UseVec14 () cil managed
{
    .locals init (
        [0] valuetype Vec14,
        [1] valuetype Vec14
    )

    // v, d 초기화
    IL_0000: ldloca.s 0
    IL_0011: call instance void Vec14::.ctor(float32, float32, float32)
    IL_0016: ldloca.s 1
    IL_0027: call instance void Vec14::.ctor(float32, float32, float32)

    // v += d
    IL_002c: ldloca.s 0                    // v 의 주소만 로드
    IL_002e: ldloc.1                       // d
    IL_002f: call instance void Vec14::op_AdditionAssignment(valuetype Vec14)
    IL_0034: ret
}

해설:

  • 메서드 이름은 op_AdditionAssignment 이며, 컴파일러가 [CompilerFeatureRequired("UserDefinedCompoundAssignmentOperators")] 를 자동으로 붙입니다. 이 속성은 "이 멤버를 이해하려면 C# 14 이상 컴파일러여야 한다" 는 메타데이터 표시로, 구버전 컴파일러가 실수로 호출하지 못하게 막는 안전 장치입니다.
  • 호출 사이트는 ldloca.s 0 으로 주소만 올립니다. stloc 이 없습니다.
  • 메서드 본문은 this 를 받아 stfld 로 필드 세 개를 직접 갱신합니다. 반환값은 없습니다.
  • 결과적으로 임시 Vec14 가 만들어지지 않고, += 이 "원본 인스턴스를 직접 수정" 으로 귀결됩니다.

3.4 디스패치 규칙

컴파일러는 a += b 를 만나면 다음 순서로 메서드를 찾습니다.

  1. a 의 타입에 op_AdditionAssignment 가 정의되어 있는가?
    • 있으면 그것을 호출.
  2. 없으면 기존 방식대로 a = a + b 로 확장.
    • 이 경로에서 op_Addition 이 필요하며, 없으면 컴파일 에러.

두 가지가 모두 정의되어 있어도 C# 14 경로가 우선 선택됩니다. 같은 타입에 operator +void operator += 를 함께 정의하는 것도 허용됩니다. 이때 두 경로가 외부에서 보기에 동일한 의미를 갖도록 맞춰 주는 것은 구현자의 몫입니다.

3.5 대상 연산자 전체

모든 복합 할당 연산자에 void operator X= 형식을 쓸 수 있고, 산술·시프트에는 checked 버전을 추가로 선언할 수 있습니다.

연산자 인스턴스 메서드 이름 checked 변종
+= op_AdditionAssignment op_CheckedAdditionAssignment
-= op_SubtractionAssignment op_CheckedSubtractionAssignment
*= op_MultiplyAssignment op_CheckedMultiplyAssignment
/= op_DivisionAssignment op_CheckedDivisionAssignment
%= op_ModulusAssignment op_CheckedModulusAssignment
&= op_BitwiseAndAssignment 없음
`\ =` op_BitwiseOrAssignment 없음
^= op_ExclusiveOrAssignment 없음
<<= op_LeftShiftAssignment 없음
>>= op_RightShiftAssignment 없음
>>>= op_UnsignedRightShiftAssignment 없음

비트 연산자는 오버플로 개념이 없으므로 checked 변종이 존재하지 않습니다.

operator checked += — checked 복합 할당 연산자 (C# 14) checked 컨텍스트 안에서 += 가 사용될 때 호출되도록 별도로 선언하는 변종이다. 오버플로가 발생하면 OverflowException 을 던지도록 구현한다. 일반 void operator += 가 "정상 모드" 구현이라면 이것은 "안전 모드" 구현이다.
예시: public void operator checked +=(Money b) { Cents = checked(Cents + b.Cents); } checked { m += x; } 블록 안에서 이 버전이 선택됨

3.6 checked 버전 IL 도 실제로 확인됨

C#
public struct Money
{
    public int Cents;
    public void operator +=(Money b)         { Cents += b.Cents; }          // add
    public void operator checked +=(Money b) { Cents = checked(Cents + b.Cents); }  // add.ovf
}
IL
.method public hidebysig specialname
    instance void op_AdditionAssignment (valuetype Money other)
{
    IL_000d: add         // 오버플로 감지 없음
    IL_000e: stfld int32 Money::Cents
}

.method public hidebysig specialname
    instance void op_CheckedAdditionAssignment (valuetype Money other)
{
    IL_000d: add.ovf     // 오버플로 시 OverflowException
    IL_000e: stfld int32 Money::Cents
}

checked { m += x; } 블록 안에서 mMoney 타입이면 컴파일러가 op_CheckedAdditionAssignment 를 선택합니다. unchecked 또는 기본 컨텍스트에서는 op_AdditionAssignment 를 선택합니다. addadd.ovf 의 IL 명령어 차이가 그대로 반영되는 점이 C# 11 의 operator checked + 와 동일한 방식입니다.


4. 실전 적용 — Before / After 와 Unity 핫패스

4.1 Before — 누적 컨테이너가 매번 새 객체를 만든다

C#
// Before: operator + 만 정의 → += 는 자동 파생
public class Basket
{
    public List<string> Items = new();

    public static Basket operator +(Basket a, string item)
    {
        var copy = new Basket();
        copy.Items.AddRange(a.Items);  // 내부 List 도 새로 생성
        copy.Items.Add(item);
        return copy;
    }
}

var basket = new Basket();
foreach (var item in stream)    // 1,000 회 반복
{
    basket += item;              // 매 반복마다 새 Basket + 새 List
}

매 루프마다 Basket 과 내부 List<string> 이 힙에 새로 할당됩니다. 1,000 회 반복이면 최소 2,000 개 의 관리 객체가 생겼다 사라집니다. 그만큼 Gen0 GC(Garbage Collector 의 0세대 수집, 가장 수명이 짧은 객체들을 회수하는 단계) 가 자주 호출됩니다.

List<T> 내부 재할당 List<T>.AddRange 는 대상 리스트의 내부 배열 용량이 모자라면 현재 크기의 두 배로 새 배열을 만들고 기존 요소를 복사한다. 여기서 "매번 새 Basket" 이 만들어진다는 건 매번 새 List<string> 과 그 내부 배열도 새로 만들어진다는 뜻이다.

4.2 Before 쪽 IL 에서 드러나는 할당

C#
public static void AppendBefore(Basket basket, string item)
{
    basket += item;
}
IL
// 간단화한 IL
IL_0000: ldarg.0        // basket
IL_0001: ldarg.1        // item
IL_0002: call class Basket Basket::op_Addition(class Basket, string)
IL_0007: starg.s basket  // basket = 반환값
IL_0009: ret

// op_Addition 내부
//   newobj Basket::.ctor()      ← 새 Basket
//   newobj List<string>::.ctor() ← 새 List
//   ... AddRange / Add

핵심은 newobj두 번 찍힌다는 점입니다. newobj 는 힙에 새 객체를 할당합니다. 반복 호출되면 그만큼 Gen0 가 가득 차고, GC 가 더 자주 실행됩니다.

4.3 After — void operator += 로 in-place 추가

C#
public class Basket
{
    public List<string> Items = new();

    // 같은 Basket 인스턴스에 직접 추가
    public void operator +=(string item)
    {
        Items.Add(item);
    }
}

var basket = new Basket();
foreach (var item in stream)
{
    basket += item;              // 매 반복마다 Items.Add 만 호출
}

1,000 번 반복해도 Basket 인스턴스는 하나, 내부 List하나 입니다. List.Add 가 내부 배열을 확장할 때 생기는 재할당을 빼면 힙 할당이 사실상 사라집니다. 공개된 벤치마크에서 보고된 "객체 할당 92% 감소" 의 근본 원인이 바로 이 차이입니다.

4.4 After 쪽 IL — newobj 가 사라진다

C#
public static void AppendAfter(Basket basket, string item)
{
    basket += item;
}
IL
IL_0000: ldarg.0        // basket
IL_0001: ldarg.1        // item
IL_0002: callvirt instance void Basket::op_AdditionAssignment(string)
IL_0007: ret
  • newobj 가 없습니다. 기존 basket 인스턴스의 메서드를 callvirt 로 호출할 뿐입니다.
  • starg 도 없습니다. basket 을 다시 덮어쓸 필요가 없기 때문입니다.
  • 클래스(참조 타입)는 call 이 아니라 callvirt 를 통해 호출되는 점이 자연스럽습니다. 구조체(값 타입)는 call 이 쓰입니다.

4.5 Unity Vector3 핫패스 — 필드 vs 프로퍼티

Vector3 가 언젠가 C# 14 += 를 도입한다고 가정하고 두 패턴을 비교해 봅시다.

필드 기반 (이득 있음)

C#
public class PlayerMover : MonoBehaviour
{
    Vector3 velocity;      // 인스턴스 필드
    [SerializeField] Vector3 gravity = new(0, -9.81f, 0);

    void Update()
    {
        velocity += gravity * Time.deltaTime;  // 필드에 직접 += → in-place
    }
}

velocity 는 필드이므로 컴파일러가 ldflda (필드 주소 로드) 로 주소를 만들 수 있습니다. Vector3op_AdditionAssignment 가 있다면 그 주소로 직접 호출 되고, 임시 Vector3 복사가 사라집니다.

Transform.position 기반 (이득 제한)

C#
void Update()
{
    transform.position += Vector3.forward * speed * Time.deltaTime;
}

Transform.position프로퍼티 입니다. 프로퍼티 대상의 += 는 다음처럼 확장됩니다.

C#
var tmp  = transform.get_position();           // 복사본 얻기 (반드시 발생)
tmp     += Vector3.forward * speed * Time.deltaTime;  // 임시 변수에 +=
transform.set_position(tmp);                    // 다시 write (엔진 쪽 반영)

Vector3op_AdditionAssignment 를 제공하더라도 한가운데의 += 한 번 만 in-place 가 되고, 앞뒤의 get/set 복사는 그대로 남습니다. 게다가 C# 14 시점에서도 "프로퍼티 좌변일 때 사용자 정의 += 를 호출할지" 자체는 언어 사양상 특별 규칙을 따르는데, 프로퍼티는 주소를 만들 수 없기 때문에 일반적으로 이 경로를 탈 수 없고, 기존 a = a + b 로 fallback 됩니다.

결론: Transform.position 같은 프로퍼티를 좌변에 둔 += 는 C# 14 혜택이 사실상 없습니다. 혜택을 받으려면 좌변이 필드 또는 ref 지역 변수여야 합니다.

4.6 Unity 실전 체크리스트

  • Vector3 velocity 같은 필드는 혜택 직행.
  • transform.position += delta 는 여전히 get/set 복사 발생 — 호출 횟수를 줄이는 게 더 중요.
  • List<T>·커스텀 버퍼 같은 가변 컨테이너의 누적 루프에서 GC 감소 효과가 크다.
  • IL2CPP(Unity 의 AOT 변환기, IL 을 C++ 로 번역해 네이티브로 컴파일) 로 빌드해도 IL → C++ 변환 시 같은 디스패치 결정이 유지된다. C++ 코드 상에서는 이 메서드가 인라인되기 쉬워 성능 혜택이 더 커질 수 있다.

5. 함정과 주의사항

5.1 좌변이 읽기 전용이면 둘 다 컴파일 에러다

readonly 필드·in 파라미터·readonly 지역변수는 수정할 수 없습니다. 사용자 정의 += 든 기존 a = a + b 든 결국 대입이 일어나야 하므로 두 경로 모두 막힙니다.

C#
// ❌ readonly 필드를 수정 시도
public class Counter
{
    public readonly Vec Position;   // readonly 필드

    public void AddDelta(Vec d)
    {
        Position += d;   // CS0191 / CS8331 — readonly 필드 수정 불가
    }
}

// ✅ 수정 가능한 필드로 두거나, 생성자 안에서만 초기화
public class Counter
{
    public Vec Position;   // 수정 가능

    public void AddDelta(Vec d)
    {
        Position += d;
    }
}

5.2 readonly struct 는 선언 자체는 가능하지만 필드를 못 고친다 (실제 검증)

직관적으로 "readonly struct 에는 void operator += 선언 자체가 막혀야 한다" 고 생각하기 쉽지만, 실제 .NET 10 preview 컴파일러로 확인한 결과는 다음과 같습니다.

C#
// 선언은 통과됨
public readonly struct RoVec
{
    public readonly float X;
    public RoVec(float x) { X = x; }

    public void operator +=(RoVec other) { }   // ✅ 컴파일 통과 (본문이 비어 있음)
}

그러나 본문에서 필드를 수정하려 하면 거기서 막힙니다.

C#
public readonly struct RoVec
{
    public readonly float X;
    public RoVec(float x) { X = x; }

    public void operator +=(RoVec other)
    {
        X += other.X;   // ❌ error CS0191: 읽기 전용 필드에는 할당할 수 없습니다
    }
}

"readonly struct 에는 선언 불가" 라는 단정은 이 컴파일러 버전에서 정확하지 않습니다. 정확한 표현은 "readonly struct 안에서 void operator += 로 상태를 바꾸는 구현이 불가능" 입니다. 결국 쓸모 있는 형태로는 정의할 수 없기 때문에 실무적으로는 "readonly struct 에는 사용하지 않는다" 가 맞는 결론이지만, 컴파일 에러 문구는 다르게 나옵니다.

관찰 사실: .NET 10 preview 컴파일러 기준. 정식 릴리스에서 더 엄격한 검사로 바뀔 가능성은 있음(추정).

5.3 의미 충돌 — ++= 가 다른 결과를 내면 안 된다

C#
// ❌ 의미가 깨지는 구현
public struct Counter
{
    public int N;

    public static Counter operator +(Counter a, int delta) => new Counter { N = a.N + delta };
    public void operator +=(int delta) { N += delta * 2; }   // 왜?? +=의 의미를 자의적으로 바꿈
}

var c = new Counter { N = 10 };
var x = c + 5;   // x.N == 15
c += 5;          // c.N == 20  — 외부에서 두 식이 다른 결과를 낸다

사용자는 c += 5c = c + 5 와 같은 결과를 낼 것이라 기대합니다. 양쪽 경로의 관찰 가능한 결과를 일치시키는 것은 구현자의 책임입니다. 다르게 동작하면 디버깅이 악몽이 됩니다.

5.4 프로퍼티 좌변에서는 여전히 get/set 복사가 남는다

4.5 에서 본 것처럼 transform.position += delta 는 C# 14 가 끼어들 자리가 없습니다. "프로퍼티 좌변 = 자동 파생 fallback" 이라고 기억해 두면 됩니다.

C#
// ❌ 혜택이 없다고 오해하면 안 되는 케이스
transform.position += delta;   // get → 임시에 += → set. get/set 복사 상수 2회
C#
// ✅ 필드 또는 지역 변수에 누적 후 한 번만 set
var pos = transform.position;
pos += delta;                  // 지역변수면 op_AdditionAssignment 경로 가능
transform.position = pos;

5.5 this 부수효과 — 스레드 안전성 착각 금지

void operator += 는 결국 this 를 수정하는 일반 인스턴스 메서드입니다. 여러 스레드가 같은 인스턴스를 동시에 += 하면 경쟁 조건이 생깁니다. Interlocked 나 락 없이 "+= 니까 원자적이겠지" 라고 생각하면 안 됩니다.

C#
// ❌ 멀티스레드에서 안전하지 않음
public class Counter
{
    public int N;
    public void operator +=(int d) => N += d;
}

// 여러 스레드가 동일 counter 에 += → 잃어버린 갱신 발생

6. C# 버전별 변화

C# 1.0 ~ C# 10

이항 연산자(+, -, *, ...) 를 static 으로만 정의할 수 있었습니다. 복합 할당 +=항상 a = a + b 로 자동 파생됩니다. 값 기반 의미론 고정.

C# 11 — operator checked 등장

이항 연산자에 대해 operator checked + 같은 "안전 모드" 변종을 정의할 수 있게 되었습니다. 이때 IL 메서드 이름은 op_CheckedAddition 등이 됩니다. 다만 복합 할당 자체는 여전히 자동 파생입니다.

C#
// C# 11
public struct Money
{
    public static Money operator +(Money a, Money b) => ...;
    public static Money operator checked +(Money a, Money b) => ...;  // C# 11
}

C# 14 — 복합 할당 연산자 자체를 선언 가능

void operator +=(...) 형식이 언어에 들어왔습니다. this 를 in-place 수정하는 의미를 명시적으로 표현할 수 있고, 컴파일러가 op_AdditionAssignment 를 우선 선택합니다. operator checked += 도 함께 선언 가능합니다.

C#
// C# 14
public struct Money
{
    public static Money operator +(Money a, Money b) => ...;
    public static Money operator checked +(Money a, Money b) => ...;
    public void operator +=(Money b) => Cents += b.Cents;                       // NEW
    public void operator checked +=(Money b) => Cents = checked(Cents + b.Cents); // NEW
}

Before / After 한눈 요약

C#
// Before (C# 13)
public struct Vec { public float X;
    public static Vec operator +(Vec a, Vec b) => new Vec { X = a.X + b.X };
}
// v += d → v = v + d → 임시 Vec 생성 → v 에 stloc

// After (C# 14)
public struct Vec { public float X;
    public static Vec operator +(Vec a, Vec b) => new Vec { X = a.X + b.X };
    public void operator +=(Vec other) => X += other.X;
}
// v += d → v.op_AdditionAssignment(d) → stfld 로 X 직접 갱신

operator + 를 남겨두는 이유: var sum = a + b; 같은 이항 식 에서는 여전히 op_Addition 이 필요하기 때문입니다. +=+= 만의 경로를 따로 쓰지만, 이항 + 를 대체하지는 않습니다.


7. 정리

이것만 기억하면 충분합니다.

  1. C# 14 부터 += · -= · *= ... 를 인스턴스 메서드(void operator +=) 로 직접 정의할 수 있다. static 이 아니고, 반환 타입은 void 다.
  2. 컴파일러는 타입에 사용자 정의 복합 할당 연산자가 있으면 그것을 우선 호출하고, 없으면 a = a + b 로 확장한다 (자동 파생).
  3. IL 이름은 op_AdditionAssignment·op_SubtractionAssignment·... 이며, 산술·시프트 계열은 op_Checked...Assignment 변종도 쓸 수 있다.
  4. 호출 사이트에서 값 타입은 ldloca 로 주소가, 참조 타입은 참조 자체가 넘어가므로 임시 인스턴스 없이 in-place 로 수정 된다. 큰 값 타입·누적 컨테이너에서 할당·복사 비용이 의미 있게 감소한다.
  5. 좌변이 readonly·in·readonly struct 필드·프로퍼티면 C# 14 경로를 탈 수 없다. 특히 transform.position += delta 는 여전히 get/set 복사가 일어나므로 성능 혜택 없음.
  6. ++= 를 동시에 제공할 때는 두 경로의 결과 값이 반드시 같아야 한다. 의미를 자의적으로 바꾸면 디버깅 재앙이다.
  7. Unity 에서 실익이 가장 큰 곳은 MonoBehaviour 필드 형태의 Vector3 / Matrix4x4 누적가변 컨테이너(커스텀 List·버퍼) 의 += 루프 다. transform.* 같은 프로퍼티는 혜택 대상이 아님을 기억하자.
반응형

+ Recent posts