같은 호출인데 객체마다 다르게 동작한다
Esc

    같은 호출인데 객체마다 다르게 동작한다

    –C#기초

    앞 글에서 전사와 마법사를 한 목록에 담았습니다. 이제 각자 공격하게 해 봅시다.

    for (int i = 0; i < party.Count; i++)
    {
        if (party[i] is Warrior w) w.Slash();
        else if (party[i] is Mage m) m.Fireball();
    }

    돌아갑니다. 그런데 궁수를 추가하면 else if 를 하나 더 적어야 합니다. 그리고 이런 갈라지기가 코드 여기저기에 흩어집니다. 공격할 때 한 벌, 이동할 때 한 벌, 저장할 때 한 벌입니다.

    직업이 하나 늘 때마다 고칠 자리가 여러 곳으로 흩어집니다.

    부르는 이름을 하나로 하고, 무엇이 실행될지는 객체가 정하게 합니다.

    virtual 과 override

    부모에 virtual 을 붙이면 “자식이 갈아 끼울 수 있다” 는 표시가 됩니다. 자식은 override 로 갈아 끼웁니다.

    class Character
    {
        public virtual void Attack()
        {
            Console.WriteLine("공격");
        }
    }
    
    class Warrior : Character
    {
        public override void Attack()
        {
            Console.WriteLine("칼로 벤다");
        }
    }
    
    class Mage : Character
    {
        public override void Attack()
        {
            Console.WriteLine("불덩이를 던진다");
        }
    }

    이제 앞의 반복문이 이렇게 됩니다.

    for (int i = 0; i < party.Count; i++)
    {
        party[i].Attack();
    }

    party[i] 는 Character 타입입니다. 그런데 실행되는 것은 그 자리에 든 객체의 것입니다.

    이걸 다형성이라고 합니다. 같은 호출이 객체에 따라 다르게 동작한다는 뜻입니다.

    앞에서 오버로딩은 컴파일할 때 정해진다고 했습니다. 오버라이딩은 실행 중에 정해집니다. 그 자리에 실제로 무엇이 들었는지를 보고 고릅니다.

    진짜 값어치는 부르는 쪽에 있다

    궁수를 추가해 봅니다.

    class Archer : Character
    {
        public override void Attack()
        {
            Console.WriteLine("화살을 쏜다");
        }
    }

    부르는 쪽 코드는 한 글자도 안 고칩니다. party[i].Attack() 그대로입니다.

    이게 다형성을 쓰는 이유입니다. 갈라지기를 없앤 것이 아니라 한 곳으로 모은 것입니다. 어느 공격을 할지는 각 클래스 안에 한 번씩 적혀 있고, 부르는 쪽은 그 판단을 아예 안 합니다.

    새 종류를 추가할 때 고칠 자리가 아니라 더할 자리만 생깁니다.

    virtual 을 안 붙이면

    virtual 없이 자식이 같은 이름의 메서드를 만들면 다르게 동작합니다.

    class Character
    {
        public void Attack() { Console.WriteLine("공격"); }
    }
    
    class Warrior : Character
    {
        public new void Attack() { Console.WriteLine("칼로 벤다"); }
    }
    
    Character c = new Warrior();
    c.Attack();      // 공격

    Warrior 를 넣었는데 부모의 것이 실행됐습니다. new 는 갈아 끼운 것이 아니라 가린 것입니다. 부모 것은 그대로 있고 이름만 겹칩니다.

    그래서 보고 있는 타입이 무엇이냐로 결정됩니다.

    Warrior w = new Warrior();
    w.Attack();      // 칼로 벤다
    
    Character c = w;
    c.Attack();      // 공격, 같은 객체인데 다릅니다

    같은 객체인데 어느 이름으로 부르냐에 따라 다르게 동작합니다. 거의 항상 버그입니다. new 로 가리는 것은 실무에서 쓸 일이 드뭅니다.

    자식이 갈아 끼울 수 있게 하려면 부모에 virtual 을 붙여야 합니다. C# 이 이렇게 만든 것은 일부러입니다. 아무 메서드나 갈아 끼울 수 있으면, 부모를 만든 사람이 예상 못 한 자리가 바뀝니다. virtual 은 “여기는 바뀌어도 된다” 고 미리 허락한 자리입니다.

    base 로 부모 것을 부를 수 있다

    갈아 끼우면서 부모 것도 하고 싶을 때가 있습니다.

    class Warrior : Character
    {
        public override void Attack()
        {
            base.Attack();                     // 부모의 Attack 을 먼저
            Console.WriteLine("칼로 벤다");
        }
    }

    base 는 부모를 가리킵니다. 생성자에서 : base(...) 를 쓴 것과 같은 base 입니다.

    공통 준비는 부모에 두고 자식은 자기 것만 더하는 식으로 쓸 수 있습니다.

    오버로딩과 오버라이딩은 다른 것이다

    이름이 비슷해서 헷갈리는데 관계가 없습니다.

    오버로딩 오버라이딩
    어디서 한 클래스 안에서 부모와 자식 사이에서
    무엇이 이름은 같고 매개변수가 다름 이름도 매개변수도 같음
    언제 정해지나 컴파일할 때 실행 중에
    하는 일 이름 하나로 여러 재료를 받음 부모의 것을 자식이 갈아 끼움
    void Print(int n)          // 오버로딩
    void Print(string s)
    
    public override void Attack()   // 오버라이딩

    ToString 은 이미 오버라이딩이었다

    모든 클래스는 object 를 물려받습니다. 안 적어도 그렇습니다. object 에 ToString 이 virtual 로 들어 있습니다.

    Console.WriteLine(w);      // Warrior

    객체를 그대로 출력하면 타입 이름이 나오는데, 그게 object 의 기본 ToString 입니다. 갈아 끼우면 원하는 대로 나옵니다.

    class Warrior : Character
    {
        public override string ToString()
        {
            return "전사 " + name;
        }
    }
    
    Console.WriteLine(w);      // 전사 바둑

    앞에서 해시 테이블을 볼 때 “직접 만든 타입은 왜 내용이 같아도 다른가” 를 남겨 뒀습니다. 답이 같은 자리에 있습니다. object 에는 Equals 와 GetHashCode 도 virtual 로 있고, 기본 구현이 내용이 아니라 만들어진 개체를 기준으로 판단합니다.

    내용으로 같다고 보게 하려면 둘 다 갈아 끼워야 합니다.

    class Point
    {
        public int X, Y;
    
        public override bool Equals(object obj)
        {
            if (obj is Point p) return X == p.X && Y == p.Y;
            return false;
        }
    
        public override int GetHashCode()
        {
            return HashCode.Combine(X, Y);
        }
    }

    둘을 같이 갈아 끼워야 합니다. 하나만 하면 해시 테이블이 깨집니다. 같다고 볼 값이 다른 칸으로 가 버리니까요.

    정리

    • 부모에 virtual 을 붙이면 자식이 override 로 갈아 끼울 수 있습니다.
    • 부모 타입으로 불러도 그 자리에 든 객체의 것이 실행됩니다. 실행 중에 정해집니다.
    • 새 자식을 추가해도 부르는 쪽 코드는 안 고칩니다. 이게 다형성을 쓰는 이유입니다.
    • virtual 없이 같은 이름을 만들면 갈아 끼운 것이 아니라 가린 것입니다. 거의 버그입니다.
    • base 로 부모의 것을 부를 수 있습니다.
    • 오버로딩은 컴파일할 때, 오버라이딩은 실행 중에 정해집니다. 이름만 비슷합니다.
    • 모든 클래스는 object 를 물려받습니다. ToString, Equals, GetHashCode 가 거기 있습니다.
    • 내용으로 같다고 보려면 Equals 와 GetHashCode 를 같이 갈아 끼웁니다.