연산자 오버로딩

연산자 오버로딩 (Operator Overloading)

이번 장에서는 D 언어가 연산자 오버로딩을 지원하는 방식을 살펴볼게요. 핵심 아이디어는 아주 단순한데요, 클래스나 구조체 객체를 피연산자로 쓰는 연산자를 특별한 이름을 가진 멤버 함수 호출로 다시 쓰는 방식입니다. 새 문법을 추가하지 않고도 연산자의 동작을 우리 타입에 맞게 다시 정의할 수 있어요.

이 장은 공식 D 언어 사양(D Language Reference)의 'Operator Overloading' 장을 한국어로 옮긴 자료입니다. 기술 용어와 식별자, 코드는 원문 그대로 보존했어요.

출처: https://dlang.org/spec/operatoroverloading.html

본문

연산자 오버로딩이란

연산자 오버로딩은 클래스나 구조체 객체를 피연산자로 쓰는 연산자를, 특별한 이름을 가진 멤버 호출로 다시 써서(rewriting) 구현돼요. 여기에 추가적인 문법은 사용되지 않아요.

단항 연산자 오버로딩 (Unary Operator Overloading)

다음 단항 연산자들을 오버로딩할 수 있어요. 왼쪽이 원래 연산식, 오른쪽이 그 연산식이 다시 쓰여진(rewrite) 모습이에요.

오버로딩 가능한 단항 연산자

op rewrite
-e e.opUnary!("-")(
+e e.opUnary!("+")(
~e e.opUnary!("~")(
*e e.opUnary!("*")(
++e e.opUnary!("++")(
--e e.opUnary!("--")(

예를 들어 구조체 S에 대해 -(부정) 연산자만, 그리고 다른 연산자는 오버로딩하지 않으려면 이렇게 하면 돼요.

struct S
{
    int m;

    int opUnary(string s)() if (s == "-")
    {
        return -m;
    }
}

void main()
{
    S s = {2};
    assert(-s == -2);
}

참고: 위의 opUnary는 템플릿 매개변수 특수화를 이용해 선언할 수도 있어요.

    int opUnary(string s : "-")()

후위 증가 e++와 후위 감소 e-- 연산자

이 연산자들은 직접 오버로딩할 수 없어요. 대신 전위 ++*e*--*e* 연산자를 바탕으로 다시 쓰여져요.

후위 연산자 재작성

op rewrite
e-- (auto t =e,e.opUnary!"--"``, t)
e++ (auto t =e,e.opUnary!"++"``, t)

인덱스 단항 연산자 오버로딩

인덱싱도 오버로딩할 수 있어요. 인덱스 표현식에 대한 단항 연산도 독립적으로 오버로딩할 수 있는데, 다차원 인덱싱에서도 동작해요.

오버로딩 가능한 인덱스 단항 연산자

op rewrite
-a[b1, b2, ... bn] a.opIndexUnary!("-")(b1, b2, ... bn)
+a[b1, b2, ... bn] a.opIndexUnary!("+")(b1, b2, ... bn)
~a[b1, b2, ... bn] a.opIndexUnary!("~")(b1, b2, ... bn)
*a[b1, b2, ... bn] a.opIndexUnary!("*")(b1, b2, ... bn)
++a[b1, b2, ... bn] a.opIndexUnary!("++")(b1, b2, ... bn)
--a[b1, b2, ... bn] a.opIndexUnary!("--")(b1, b2, ... bn)
struct S
{
    private int[] a;

    void opIndexUnary(string s: "++")(size_t i) { ++a[i]; }
}

S s = {[4]};
++s[0];
assert(s.a[0] == 5);

슬라이스 단항 연산자 오버로딩

슬라이싱도 오버로딩할 수 있어요. 슬라이스에 대한 단항 연산도 독립적으로 오버로딩할 수 있는데, opIndexUnary는 전체 슬라이스에 대해서는 함수 인자 없이, 슬라이스의 시작·끝 인덱스에 대해서는 두 개의 인자로 정의돼요.

오버로딩 가능한 슬라이스 단항 연산자

op rewrite
-a[i..j] a.opIndexUnary!("-")(a.opSlice(i, j))
+a[i..j] a.opIndexUnary!("+")(a.opSlice(i, j))
~a[i..j] a.opIndexUnary!("~")(a.opSlice(i, j))
*a[i..j] a.opIndexUnary!("*")(a.opSlice(i, j))
++a[i..j] a.opIndexUnary!("++")(a.opSlice(i, j))
--a[i..j] a.opIndexUnary!("--")(a.opSlice(i, j))
-a[ ] a.opIndexUnary!("-")()
+a[ ] a.opIndexUnary!("+")()
~a[ ] a.opIndexUnary!("~")()
*a[ ] a.opIndexUnary!("*")()
++a[ ] a.opIndexUnary!("++")()
--a[ ] a.opIndexUnary!("--")()
struct S
{
    private int[] a;

    void opIndexUnary(string s: "--")() { --a[]; }
}

S s = {[1, 2]};
--s[];
assert(s.a == [0, 1]);

레거시(Legacy): 이전 버전과의 호환을 위해, 위의 rewrite가 컴파일되지 않고 opSliceUnary가 정의되어 있다면 각각 a.opSliceUnary!(op)(i, j)a.opSliceUnary!(op) 재작성이 대신 시도돼요.

캐스트 연산자 오버로딩 (Cast Operator Overloading)

한 타입을 다른 타입으로 캐스트하는 방법을 정의하려면 opCast 템플릿 메서드를 정의하면 돼요. 이렇게 쓰여요.

캐스트 연산자

op rewrite
cast(type)e e.opCast!(type)()

opCast는 명시적 cast 표현식에서만 사용되는데, 유일한 예외가 다음 절에서 볼 불리언 연산이에요.

struct S
{
    void* mem;

    bool opCast(T)()
    if (is(T == bool)) => mem !is null;
}

S s = S(new int);
auto b = cast(bool) s;
assert(b);
//b = s; // error

opCast의 반환 타입이 cast의 type 매개변수와 다르다면, 결과는 type으로 암시적으로 변환돼요.

불리언 연산 (Boolean Operations)

오버로딩 가능한 단항 연산자 목록에서 눈에 띄게 빠진 게 ! 논리 부정 연산자예요. 더 눈치채기 어렵게 빠진 것은 bool 결과로 변환해 주는 단항 연산자예요. 구조체는 이 둘을 다음 rewrite로 처리해요.

opCast!(bool)(e)

그래서 이렇게 다시 쓰여져요.

if (e)   =>  if (e.opCast!(bool))
if (!e)  =>  if (!e.opCast!(bool))

구조체 인스턴스에 쓰이는 다른 불리언 조건 표현식과 논리 연산자도 마찬가지예요. 다만 이건 구조체 인스턴스에서만 일어나요. 클래스 참조는 참조가 null인지 아닌지를 검사해서 bool로 변환돼요.

이항 연산자 오버로딩 (Binary Operator Overloading)

다음 이항 연산자들을 오버로딩할 수 있어요.

오버로딩 가능한 이항 연산자

+ - * / % ^^ &
` ` ^ <``< >``> >``>``> ~

표현식

a op b

은 다음 중 하나로 다시 쓰여져요.

a.opBinary!("op")(b)
b.opBinaryRight!("op")(a)

그리고 '더 나은(better)' 매치를 가진 쪽이 선택돼요. 둘 다 동등하게 매치되면 오류예요. 예시를 볼게요.

struct S
{
    int[] data;

    // this ~ rhs
    int[] opBinary(string op : "~")(int rhs)
    {
        return data ~ rhs;
    }
    // lhs ~ this
    int[] opBinaryRight(string op : "~")(int lhs)
    {
        return lhs ~ data;
    }
}

void main()
{
    auto s = S([2,3]);
    assert(s ~ 4 == [2,3,4]); // opBinary
    assert(1 ~ s == [1,2,3]); // opBinaryRight
}

여러 연산자를 한 번에 오버로딩할 수도 있어요. 예를 들어 +-만 지원한다면 이렇게 하면 되죠.

T opBinary(string op)(T rhs)
{
    static if (op == "+") return data + rhs.data;
    else static if (op == "-") return data - rhs.data;
    else static assert(0, "Operator " ~ op ~ " not implemented");
}

모두 한꺼번에 처리하려면 이렇게요.

T opBinary(string op)(T rhs)
{
    return mixin("data " ~ op ~ " rhs.data");
}

참고로 opInopIn_r은 각각 opBinary!"in"opBinaryRight!"in"을 권장하면서 더 이상 쓰지 않게(deprecated) 됐어요.

비교 연산자 오버로딩 (Overloading the Comparison Operators)

D는 비교 연산자 ==, !=, <, <=, >=, >opEqualsopCmp 두 함수를 통해 오버로딩할 수 있게 해줘요.

동등 연산자와 부등 연산자는 비교 연산자와 별도로 취급돼요. 거의 모든 사용자 정의 타입은 동등성을 비교할 수 있지만, 의미 있는 순서(ordering)를 갖는 타입은 그 일부뿐이기 때문이에요. 예를 들어 두 RGB 색상 벡터가 같은지를 판단하는 건 말이 되지만, 색상에는 순서가 없으므로 한 색이 다른 색보다 크다고 말하는 건 의미가 없어요. 그래서 Color 타입에는 opEquals를 정의하되 opCmp는 정의하지 않는 식이에요.

게다가 순서가 있는 타입에서도 그 순서 관계가 선형이 아닐 수 있어요. 예를 들어 집합(set)에 부분집합 관계로 순서를 정의한다고 해봐요. xy의 (엄격한) 부분집합일 때 x < y가 참이 되는 식이에요. 만약 xy가 서로소 집합이라면 x < yy < x도 성립하지 않는데, 그렇다고 x == y라는 뜻은 아니에요. 따라서 동등성을 opCmp만으로 판단하는 건 충분하지 않아요. 이런 이유로 opCmpRelExpression 연산자 <, <=, >=, >에만 쓰여요. 동등 연산자 ==!=는 항상 opEquals를 사용해요.

그러므로 opCmpopEquals가 서로 일관되게 동작하도록 하는 건 프로그래머의 책임이에요. 구조체에 opEquals를 지정하지 않으면 컴파일러가 멤버별 비교(member-wise comparison)를 수행하는 기본 버전을 제공해요. 이것으로 충분하다면 opEquals는 정의하지 않고 opCmp만 정의해 RelExpression 연산자의 동작을 바꿀 수 있어요. 하지만 만약 충분하지 않다면, 두 종류의 비교 연산자 사이에 일관된 의미를 유지하기 위해 opEquals의 사용자 정의 버전도 함께 정의해야 해요.

마지막으로, 사용자 정의 타입을 내장 연관 배열의 키로 쓰려면 opEqualstoHash의 의미가 일관적이도록 보장해야 해요. 그렇지 않으면 연관 배열이 기대한 대로 동작하지 않을 수 있어요.

==와 != 오버로딩

a != b 형태의 표현식은 !(a == b)로 다시 쓰여져요.

a == b가 주어졌을 때:

  1. ab가 둘 다 클래스 객체라면, 표현식은 다음과 같이 다시 쓰여져요.
.object.opEquals(a, b)

그리고 그 함수는 대략 이렇게 생겼어요.

bool opEquals(Object a, Object b)
{
    if (a is b) return true;
    if (a is null || b is null) return false;
    if (typeid(a) == typeid(b)) return a.opEquals(b);
    return a.opEquals(b) && b.opEquals(a);
}
  1. 그 외의 경우엔 a.opEquals(b)b.opEquals(a) 둘 다 시도돼요. 둘 다 같은 opEquals 함수로 해석되면 표현식은 a.opEquals(b)로 다시 쓰여져요.
  2. 한쪽이 다른 쪽보다 더 나은 매치이거나, 한쪽은 컴파일되고 다른 쪽은 컴파일되지 않으면 첫 번째 것이 선택돼요.
  3. 그 외의 경우엔 오류가 발생해요.

클래스에서 Object.opEquals()를 오버라이딩한다면, 클래스 멤버 함수는 Object 매개변수를 받아 그 객체가 호환되는 클래스인지 동적으로 확인해야 해요. 예를 들면 이렇게요.

class C
{
    int i;

    this(int i) { this.i = i; }

    override bool opEquals(Object o)
    {
        if (auto c = cast(C) o)
            return c.i == i;
        else
            assert(0, __FUNCTION__ ~ ": Cannot compare a " ~ typeid(o).toString);
    }
}

static assert(new C(2) == new C(2));
static assert(new C(2) != new C(3));

구조체가 동일성 비교를 위해 opEquals 멤버 함수를 선언한다면, 여러 형태가 가능해요. 예를 들어 이렇게요.

struct S
{
    // lhs should be mutable object
    bool opEquals(const S s) { ... }        // for r-values (e.g. temporaries)
    bool opEquals(ref const S s) { ... }    // for l-values (e.g. variables)

    // both hand side can be const object
    bool opEquals(const S s) const { ... }  // for r-values (e.g. temporaries)
}

또는 auto ref 매개변수를 가진 단일 템플릿 opEquals 함수를 선언할 수도 있어요.

struct S
{
    // for l-values and r-values,
    // with converting both hand side implicitly to const
    bool opEquals()(auto ref const S s) const { ... }
}

참고: 구조체에서 opEquals를 구현하는 모범 사례는 Struct Equality를 참고하세요.

<, <=, >, >= 오버로딩

클래스 참조는 먼저 참조로 비교돼요. 서로 다른 객체를 가리키고 둘 다 null이 아니라면, 그다음 구조체처럼 해당하는 opCmp 메서드를 호출해 비교해요.

구조체 비교 연산은 다음과 같이 다시 쓰여져요.

비교 연산의 재작성

comparison rewrite 1 rewrite 2
a``<``b a.opCmp(b)``<``0 b.opCmp(a)``>``0
a``<``= b a.opCmp(b)``<``= 0 b.opCmp(a)``>``= 0
a``>``b a.opCmp(b)``>``0 b.opCmp(a)``<``0
a``>``= b a.opCmp(b)``>``= 0 b.opCmp(a)``<``= 0

두 rewrite 모두 시도돼요. 하나만 컴파일되면 그것이 선택되고, 둘 다 같은 함수로 해석되면 첫 번째 rewrite가 수행돼요. 서로 다른 함수로 해석되면 더 잘 매치되는 것이 사용돼요. 둘 다 동등하게 매치되는데 서로 다른 함수라면 모호성(ambiguity) 오류가 발생해요.

struct B
{
    int opCmp(int)         { return -1; }
    int opCmp(ref const S) { return -1; }
    int opCmp(ref const C) { return -1; }
}

struct S
{
    int opCmp(ref const S) { return 1; }
    int opCmp(ref B)       { return 0; }
}

struct C
{
    int opCmp(ref const B) { return 0; }
}

void main()
{
    S s;
    const S cs;
    B b;
    C c;
    assert(s > s);      // s.opCmp(s) > 0
    assert(!(s < b));   // s.opCmp(b) > 0  - S.opCmp(ref B) is exact match
    assert(!(b < s));   // s.opCmp(b) < 0  - S.opCmp(ref B) is exact match
    assert(b < cs);     // b.opCmp(s) < 0  - B.opCmp(ref const S) is  exact match
    static assert(!__traits(compiles, b < c)); // both C.opCmp and B.opcmp match exactly
}

클래스에서 Object.opCmp()를 오버라이딩한다면, 클래스 멤버 함수는 Object 매개변수를 받아 비교가 가능한 호환 클래스인지 동적으로 확인해야 해요(opEquals를 오버라이딩할 때처럼요).

class C
{
    override int opCmp(Object o) { ... }
}

구조체가 opCmp 멤버 함수를 선언한다면 다음과 같은 형태여야 해요.

struct S
{
    int opCmp(ref const S s) const { ... }
}

참고로 opCmpRelExpression 연산자에서만 쓰여요. a == b 같은 표현식은 항상 opEquals를 사용해요.

구조체에서 opCmp는 정의했는데 opEquals는 정의하지 않으면, 컴파일러가 멤버별 비교를 수행하는 기본 버전의 opEquals를 제공해요. 그런데 멤버별 비교가 사용자 정의 opCmp와 일관되지 않을 수 있어요. 적절할 때 opEquals 버전을 직접 제공하는 건 프로그래머 몫이에요. 그렇지 않으면 a <= b 같은 RelExpressiona == b 같은 동등식과 불일치하게 동작해요.

struct S
{
    int i, j;
    int opCmp(ref const S s) const { return (i > s.i) - (i < s.i); } // ignore j
}

S a = {2, 3};
S b = {2, 1};
S c = {3, 0};
assert(a < c);
assert(a <= b);
assert(!(a < b)); // opCmp ignores j
assert(a != b);   // generated opEquals tests both i and j members

모범 사례: 정수를 비교할 때 i - s.i 대신 (i > s.i) - (i < s.i)를 쓰면 오버플로를 피할 수 있어요.

함수 호출 연산자 오버로딩 (Function Call Operator Overloading)

함수 호출 연산자 ()opCall이라는 이름의 함수를 선언해 오버로딩할 수 있어요.

struct F
{
    int opCall();
    int opCall(int x, int y, int z);
}

void main()
{
    F f;
    int i;

    i = f();      // same as i = f.opCall();
    i = f(3,4,5); // same as i = f.opCall(3,4,5);
}

이런 방식으로 구조체나 클래스 객체가 마치 함수처럼 동작하게 만들 수 있어요.

struct Multiplier
{
    int factor;
    this(int num) { factor = num; }
    int opCall(int value) { return value * factor; }
}

void main()
{
    Multiplier m = Multiplier(10);  // invoke constructor
    assert(m.factor == 10);
    int result = m(5);              // invoke opCall
    assert(result == 50);
}

static opCall

static opCall도 타입 이름과 함께 쓰이는 함수 호출 연산자로서 기대한 대로 동작해요.

struct Double
{
    static int opCall(int x) { return x * 2; }
}

void main()
{
    int i = Double(2);
    assert(i == 4);
}

참고로 static opCall을 선언하는 것만으로 구조체 리터럴 문법이 자동으로 비활성화돼요. 구조체 생성자와 static opCall을 섞는 것도 허용되지 않아요.

struct S
{
    this(int i) {}
    static S opCall()  // disallowed due to constructor
    {
        return S.init;
    }
}

참고: static opCall로 인자가 없는 구조체 생성자를 흉내 낼 수는 있지만, 이건 권장되는 방법이 아니에요. 대신 구조체 인스턴스를 만들 때는 팩토리 함수를 쓰는 게 선호돼요.

할당 연산자 오버로딩 (Assignment Operator Overloading)

할당 연산자 =는 좌변이 집계(aggregate) 타입이고 그 집계의 멤버 함수로 opAssign이 있을 때 오버로딩할 수 있어요.

구조체 타입은 항등 할당(identity assignment)에 대한 오버로딩뿐 아니라 다른 타입으로부터의 할당도 허용해요.

struct S
{
    // identity assignment, allowed.
    void opAssign(S rhs);

    // not identity assignment, also allowed.
    void opAssign(int);
}
S s;
s = S();      // Rewritten to s.opAssign(S());
s = 1;        // Rewritten to s.opAssign(1);

그러나 클래스 타입은 typeof(this)로 변환되지 않는 타입으로부터의 할당만 오버라이딩할 수 있어요. 이유: 모든 클래스 타입은 참조 의미론을 가지므로, 항등 할당은 좌변의 클래스 참조를 우변의 참조로 다시 바인딩해요. 이것을 오버라이딩할 수 있게 하면, 서브클래스가 베이스 클래스로 암시적으로 변환되는 것과 개념적으로 충돌하게 돼요.

class C
{
    // If X is the same type as C or a type which is
    // implicitly convertible to C, then opAssign would
    // accept identity assignment, which is disallowed.
    // C opAssign(...);
    // C opAssign(X);
    // C opAssign(X, ...);
    // C opAssign(X ...);
    // C opAssign(X, U = defaultValue, etc.);

    // not an identity assignment - allowed
    void opAssign(int);
}
C c = new C();
c = new C();  // Rebinding reference
c = 1;        // Rewritten to c.opAssign(1);

인덱스 할당 연산자 오버로딩

할당의 좌변이 구조체나 클래스 인스턴스에 대한 인덱스 연산이라면, opIndexAssign 멤버 함수를 제공해 오버로딩할 수 있어요.

op rewrite
a[*b*1, *b*2, ... *b*n] = c a.opIndexAssign(c, *b*1, *b*2, ... *b*n)
struct A
{
    int opIndexAssign(int value, size_t i1, size_t i2);
}

void test()
{
    A a;
    size_t i;
    a[i,3] = 7;  // same as a.opIndexAssign(7,i,3);
}

슬라이스 할당 연산자 오버로딩

할당의 좌변이 구조체나 클래스 인스턴스에 대한 슬라이스 연산이라면, opSlice 함수의 반환 값을 매개변수로 받는 opIndexAssign 멤버 함수를 구현해 오버로딩할 수 있어요.

op rewrite
a[] = c a.opIndexAssign(c)
a[*i*..*j*] = c a.opIndexAssign(c, a.opSlice!0(*i*, *j*))

자세한 내용은 Slice Operator Overloading을 참고하세요.

struct A
{
    int opIndexAssign(int v);  // overloads a[] = v
    int opIndexAssign(int v, size_t[2] slice);  // overloads a[i .. j] = v
    size_t[2] opSlice(size_t dim)(size_t i, size_t j);  // overloads i .. j
}

void test()
{
    A a;
    int v;

    a[] = v;  // same as a.opIndexAssign(v);
    a[3..4] = v;  // same as a.opIndexAssign(v, a.opSlice!0(3,4));
}

레거시(Legacy): 이전 버전과의 호환을 위해, a[*i*..*j*]a.opIndexAssign(a.opSlice!0(*i*, *j*))로 다시 쓰는 게 컴파일되지 않으면, 대신 레거시 rewrite인 opSliceAssign(c, *i*, *j*)가 사용돼요.

복합 할당(op assignment) 연산자 오버로딩

다음 op assignment 연산자들을 오버로딩할 수 있어요.

오버로딩 가능한 op assignment 연산자

+= -= *= /= %``= ^^= &``=
` ``=` ^= <``<``= >``>``= >``>``>``= ~=

표현식

a op= b

은 다음과 같이 다시 쓰여져요.

a.opOpAssign!("op")(b)

예시를 볼게요.

struct S
{
    int i;
    void opOpAssign(string op: "+")(int rhs) { i += rhs; }
}

S s = {2};
s += 3;
assert(s.i == 5);

인덱스 op assignment 연산자 오버로딩

op=의 좌변이 구조체나 클래스 인스턴스에 대한 인덱스 표현식이고 opIndexOpAssign이 멤버라면:

a[b1, b2, ... bn] op= c

이런 표현식은 이렇게 다시 쓰여져요.

a.opIndexOpAssign!("op")(c, b1, b2, ... bn)

슬라이스 op assignment 연산자 오버로딩

op=의 좌변이 구조체나 클래스 인스턴스에 대한 슬라이스 표현식이고 opIndexOpAssign이 멤버라면:

a[i..j] op= c

이런 표현식은 이렇게 다시 쓰여져요.

a.opIndexOpAssign!("op")(c, a.opSlice(i, j))

그리고

a[] op= c

이런 표현식은 이렇게 다시 쓰여져요.

a.opIndexOpAssign!("op")(c)

레거시(Legacy): 이전 버전과의 호환을 위해, 위의 rewrite들이 실패하고 opSliceOpAssign이 정의되어 있다면 각각 a.opSliceOpAssign(c, i, j)a.opSliceOpAssign(c) rewrite가 대신 시도돼요.

배열 인덱싱·슬라이싱 연산자 오버로딩

배열 인덱싱과 슬라이싱 연산자는 opIndex, opSlice, opDollar 메서드를 구현해 오버로딩해요. 이들을 조합하면 다차원 배열을 구현할 수 있어요.

인덱스 연산자 오버로딩

arr[*b*1, *b*2, ... *b*n] 형태의 표현식은 arr.opIndex(*b*1, *b*2, ... *b*n)으로 변환돼요. 예를 들면 이렇게요.

struct A
{
    int opIndex(size_t i1, size_t i2, size_t i3);
}

void test()
{
    A a;
    int i;
    i = a[5,6,7];  // same as i = a.opIndex(5,6,7);
}

이런 방식으로 구조체나 클래스 객체가 마치 배열처럼 동작하게 만들 수 있어요. 인덱스 표현식을 opIndexAssign이나 opIndexOpAssign으로 다시 쓸 수 있다면 그쪽이 opIndex보다 우선해요.

슬라이스 연산자 오버로딩

슬라이싱 연산자를 오버로딩한다는 건 a[]a[*i*..*j*] 같은 표현식을 오버로딩한다는 뜻이에요. 여기서 대괄호 안의 표현식은 *i*..*j* 형태의 슬라이스 표현식을 담고 있어요.

a[]를 오버로딩하려면 매개변수 없는 opIndex만 정의하면 돼요.

op rewrite
a[] a.opIndex()
struct S
{
    int[] impl;
    int[] opIndex()
    {
        return impl[];
    }
}

void main()
{
    auto s = S([1,2,3]);
    int[] t = s[]; // calls s.opIndex()
    assert(t == [1,2,3]);
}

a[*i*..*j*] 형태의 배열 슬라이싱을 오버로딩하려면 두 단계가 필요해요. 먼저 슬라이스 인자 *i*..*j*opSlice 호출로 변환해 양 끝점 *i**j*를 담는 객체를 만들고, 그다음 그 객체를 opIndex에 넘겨 실제 슬라이싱을 수행해요.

op rewrite
a[*i*..*j*] a.opIndex(a.opSlice(*i*, *j*))

아래에서 opSliceint[]를 반환하므로 opIndex는 이 타입을 받아야 해요.

struct S
{
    int[] impl;

    int[] opSlice(size_t i, size_t j)
    {
        return impl[i..j];
    }
    int[] opIndex()(int[] slice) { return slice; }
}

void main()
{
    auto s = S([1, 2, 3]);
    int[] t = s[0..2]; // calls s.opIndex(s.opSlice(0, 2))
    assert(t == [1, 2]);
}

이 설계는 다차원 배열에서 인덱싱과 슬라이싱을 섞어 쓰는 걸 지원하기 위해 선택됐어요. 예를 들어 arr[1, 2..3, 4] 같은 표현식을 변환할 때 쓰이죠. 더 정확히 말하면, arr[*b*1, *b*2, ... *b*n] 형태의 표현식은 arr.opIndex(*c*1, *c*2, ... *c*n)으로 변환돼요. 각 인자 *b*i는 다음 둘 중 하나가 될 수 있어요.

  • 단일 표현식인 경우: 그대로 opIndex의 대응 인자 *c*i로 전달돼요.
  • *x*i..*y*i 형태의 슬라이스 인자인 경우: opIndex의 대응 인자 *c*iarr.opSlice!*i*(*x*i, *y*i)가 돼요. *n*이 1이면 opSlice는 템플릿 매개변수가 필요 없어요.

예시를 볼게요.

op rewrite
arr[1, 2, 3] arr.opIndex(1, 2, 3)
arr[1..2, 3..4, 5..6] arr.opIndex(arr.opSlice!0(1,2), arr.opSlice!1(3,4), arr.opSlice!2(5,6))
arr[1, 2..3, 4] arr.opIndex(1, arr.opSlice!1(2,3), 4)

슬라이싱을 포함하는 할당 연산자에도 비슷한 변환이 적용돼요. 예를 들어요.

op rewrite
arr[1, 2..3, 4] = c arr.opIndexAssign(c, 1, arr.opSlice!1(2, 3), 4)
arr[2, 3..4] += c arr.opIndexOpAssign!"+"(c, 2, arr.opSlice!1(2, 3))

의도는 opSlice!i가 배열의 i번째 차원을 따라 인덱스 구간(interval)을 나타내는 사용자 정의 객체를 반환하도록 하는 거예요. 그러면 이 객체가 opIndex에 전달되어 실제 슬라이싱 연산이 수행돼요.

모든 경우에 arr은 딱 한 번만 평가된다는 점을 기억하세요. 그래서 getArray()[1, 2..3, $-1] = c 같은 표현식은 다음처럼 동작해요.

auto __tmp = getArray();
__tmp.opIndexAssign(c, 1, __tmp.opSlice!1(2,3), __tmp.opDollar!2 - 1);

여기서 getArray() 함수 호출은 딱 한 번만 실행돼요.

레거시(Legacy): 이전 버전과의 호환을 위해, a[]a[*i*..*j*]는 각각 인자 없는 opSlice()와 두 인자를 가진 opSlice(*i*, *j*)를 구현해서도 오버로딩할 수 있어요. 이건 1차원 슬라이싱에만 적용되며, D가 아직 다차원 배열을 완전히 지원하지 않던 시절에서 비롯된 거예요. 이런 opSlice 사용법은 권장되지 않아요.

달러($) 연산자 오버로딩

배열 인덱스·슬라이싱 연산자의 인자 안에서 $opDollar!i로 변환돼요. 여기서 i$ 표현식이 나타난 위치예요. 예를 들면 이렇게요.

op rewrite
arr[$-1, $-2, 3] arr.opIndex(arr.opDollar!0 - 1, arr.opDollar!1 - 2, 3)
arr[1, 2, 3..$] arr.opIndex(1, 2, arr.opSlice!2(3, arr.opDollar!2))

의도는 opDollar!i가 배열의 i번째 차원을 따른 길이, 또는 그 차원을 따른 배열의 끝을 나타내는 사용자 정의 객체를 반환하도록 하는 거예요. opSliceopIndex가 이해할 수 있는 값이어야 하죠.

struct Rectangle
{
    int width, height;
    int[][] impl;

    this(int w, int h)
    {
        width = w;
        height = h;
        impl = new int[][](w, h);
    }
    int opIndex(size_t i1, size_t i2)
    {
        return impl[i1][i2];
    }
    int opDollar(size_t pos)()
    {
        static if (pos==0)
            return width;
        else
            return height;
    }
}

void main()
{
    auto r = Rectangle(10,20);
    int i = r[$-1, 0];    // same as: r.opIndex(r.opDollar!0, 0),
                          // which is r.opIndex(r.width-1, 0)
    int j = r[0, $-1];    // same as: r.opIndex(0, r.opDollar!1)
                          // which is r.opIndex(0, r.height-1)
}

위 예시가 보여주듯, $가 어느 인자에 나타나는지에 따라 opDollar에 서로 다른 컴파일 타임 인자가 전달돼요. 첫 번째 인자에 나타난 $opDollar!0으로, 두 번째 인자에 나타난 $opDollar!1으로 변환되는 식이에요. 그래서 다차원 배열을 구현할 때 각 차원에 맞는 $의 값을 반환할 수 있어요.

참고로 opDollar!i는 인덱싱 연산의 해당 위치에 $가 나타난 각각의 i에 대해 딱 한 번만 평가돼요. 그래서 arr[$-sqrt($), 0, $-1] 같은 표현식은 다음처럼 동작해요.

auto __tmp1 = arr.opDollar!0;
auto __tmp2 = arr.opDollar!2;
arr.opIndex(__tmp1 - sqrt(__tmp1), 0, __tmp2 - 1);

만약 opIndex가 인자를 하나만 받도록 선언됐다면 opDollar의 컴파일 타임 인자는 생략할 수 있어요. 그 경우엔 인자가 두 개 이상인 배열 인덱싱 표현식 안에서 $를 쓰는 건 불법이에요.

완전한 예제 (Complete Example)

아래 코드 예시는 인덱싱과 슬라이싱 연산자를 오버로딩한 2차원 배열의 간단한 구현을 보여줘요. 여기 쓰인 여러 구성 요소에 대한 설명은 뒤따르는 절들에서 다뤄요.

struct Array2D(E)
{
    E[] impl;
    int stride;
    int width, height;

    this(int width, int height, E[] initialData = [])
    {
        impl = initialData;
        this.stride = this.width = width;
        this.height = height;
        impl.length = width * height;
    }

    // Index a single element, e.g., arr[0, 1]
    ref E opIndex(int i, int j) { return impl[i + stride*j]; }

    // Array slicing, e.g., arr[1..2, 1..2], arr[2, 0..$], arr[0..$, 1].
    Array2D opIndex(int[2] r1, int[2] r2)
    {
        Array2D result;

        auto startOffset = r1[0] + r2[0]*stride;
        auto endOffset = r1[1] + (r2[1] - 1)*stride;
        result.impl = this.impl[startOffset .. endOffset];

        result.stride = this.stride;
        result.width = r1[1] - r1[0];
        result.height = r2[1] - r2[0];

        return result;
    }
    auto opIndex(int[2] r1, int j) { return opIndex(r1, [j, j+1]); }
    auto opIndex(int i, int[2] r2) { return opIndex([i, i+1], r2); }

    // Support for `x..y` notation in slicing operator for the given dimension.
    int[2] opSlice(size_t dim)(int start, int end)
        if (dim >= 0 && dim < 2)
    in { assert(start >= 0 && end <= this.opDollar!dim); }
    do
    {
        return [start, end];
    }

    // Support `$` in slicing notation, e.g., arr[1..$, 0..$-1].
    @property int opDollar(size_t dim : 0)() { return width; }
    @property int opDollar(size_t dim : 1)() { return height; }
}

void main()
{
    auto arr = Array2D!int(4, 3, [
        0, 1, 2,  3,
        4, 5, 6,  7,
        8, 9, 10, 11
    ]);

    // Basic indexing
    assert(arr[0, 0] == 0);
    assert(arr[1, 0] == 1);
    assert(arr[0, 1] == 4);

    // Use of opDollar
    assert(arr[$-1, 0] == 3);
    assert(arr[0, $-1] == 8);   // Note the value of $ differs by dimension
    assert(arr[$-1, $-1] == 11);

    // Slicing
    auto slice1 = arr[1..$, 0..$];
    assert(slice1[0, 0] == 1 && slice1[1, 0] == 2  && slice1[2, 0] == 3 &&
           slice1[0, 1] == 5 && slice1[1, 1] == 6  && slice1[2, 1] == 7 &&
           slice1[0, 2] == 9 && slice1[1, 2] == 10 && slice1[2, 2] == 11);

    auto slice2 = slice1[0..2, 1..$];
    assert(slice2[0, 0] == 5 && slice2[1, 0] == 6 &&
           slice2[0, 1] == 9 && slice2[1, 1] == 10);

    // Thin slices
    auto slice3 = arr[2, 0..$];
    assert(slice3[0, 0] == 2 &&
           slice3[0, 1] == 6 &&
           slice3[0, 2] == 10);

    auto slice4 = arr[0..3, 2];
    assert(slice4[0, 0] == 8 && slice4[1, 0] == 9 && slice4[2, 0] == 10);
}

포워딩 (Forwarding)

클래스나 구조체에서 찾을 수 없는 멤버 이름은 opDispatch라는 템플릿 함수로 포워딩되어 해석될 수 있어요.

import std.stdio;

struct S
{
    void opDispatch(string s, T)(T i)
    {
        writefln("S.opDispatch('%s', %s)", s, i);
    }
}

class C
{
    void opDispatch(string s)(int i)
    {
        writefln("C.opDispatch('%s', %s)", s, i);
    }
}

struct D
{
    template opDispatch(string s)
    {
        enum int opDispatch = 8;
    }
}

void main()
{
    S s;
    s.opDispatch!("hello")(7);
    s.foo(7);

    auto c = new C();
    c.foo(8);

    D d;
    writefln("d.foo = %s", d.foo);
    assert(d.foo == 8);
}

D1 스타일 연산자 오버로딩

D1 연산자 오버로딩 메커니즘은 더 이상 쓰지 않게(deprecated) 됐어요.

더 알아보기 (Learn more)

  • 연산자 오버로딩 개요 — 이 장의 핵심: 연산자를 opUnary, opBinary, opCall, opIndex 같은 특별한 이름의 멤버 호출로 다시 쓰는 방식입니다.
  • opEquals / opCmp — 동등 비교와 순서 비교를 분리해 다루는 D의 독특한 설계. 비교 연산자 오버로딩 절을 참고하세요.
  • 다차원 배열과 $opIndex, opSlice, opDollar를 조합해 다차원 배열을 구현하는 방법(완전한 예제 참고).
  • opDispatch — 존재하지 않는 멤버 호출을 템플릿 함수로 포워딩하는 유연한 메커니즘.
  • 관련 공식 문서: D Language Reference — Expressions, Struct Equality