Pv_log

OOP의 다형성, Overloading과 Overriding 그리고 global:: 본문

Develop Study/Unity

OOP의 다형성, Overloading과 Overriding 그리고 global::

Priv 2024. 5. 13. 09:34


 

 

1. 오버로딩 (Overloading)

C#에서 사용되는 메서드는 OOP 언어의 개념 중 하나인 다형성에 기반하여 오버로딩이라는 기능을 사용할 수 있습니다.

원래 메서드는 고유한 이름을 이용해 정의됩니다.

메서드의 이름은 메서드 호출 시에 사용되기 때문에 말 그대로 '이름'의 의미를 지닙니다.

하지만 사람들의 이름처럼 메서드의 이름도 '동명이인'이 존재할 수 있습니다.

우리가 알고 있는 동명이인이라는 개념을 조금 더 살펴보자면, '이름'은 같지만 '존재'는 다른 두 명 이상의 사람이라고 말할 수 있습니다.

여기서 '존재'는 뭘까요?

더 쉽게 비유하자면, '알맹이'에 가깝습니다.

얼굴도 다르고, 가정도 다르고, 나이도, 태어난 곳도 다른, 말 그대로 '다른 존재'입니다.

그 존재를 호출하는 데 사용되는 이름이라는 것만 같을 뿐, 알맹이는 전혀 다른 겁니다.

이 개념을 메서드에 대입한다면 메서드를 호출하는 이름은 같지만, 그 기능은 다르다는 것이 됩니다.

public void Hello() {
    print("Hello.");
}
public void Hello(string name) {
    print("Hello " + name);
}

위의 두 메서드는 이름이 같습니다.

둘 다 Hello라는 이름을 쓰고 있죠.

하지만 두 메서드는 '알맹이'가 다릅니다.

하나는 단순히 "Hello." 문장을 출력하고, 다른 하나는 "Hello " 와 함께 매개변수로 넘어온 name 변수 값을 붙여서 출력합니다.

이를 구분하기 위해서는 매개변수에 집중해야 합니다.

public static void Main(string[] args) {
    Hello();
    Hello("Kim");
}

위와 같이 코드를 작성해 사용하면 이름만 같을 뿐, 알맹이가 다른 메서드를 호출했다는 걸 우리도 구분할 수 있습니다.

물론, 컴퓨터도 마찬가지입니다.

Hello()라는 메서드 이름은 같았지만, 매개변수의 종류가 다르기 때문에 두 메서드가 동명이인일 뿐, 알맹이는 다르다는 것을 파악할 수 있습니다.

단, 여기서 "접근제한자는 동명이인(메서드)을 구분하는 데 영향을 미치는가?"라는 문제가 남아있습니다.

이를 추상적으로 조금 억지스럽게 다시 비유하자면, '집에서만 호출할 수 있는 김 씨'와 '학교에서만 호출할 수 있는 김 씨' 정도로 말할 수 있을 것입니다.

결론부터 말하자면 컴퓨터는 이 둘을 구분할 수 없습니다.

여기서 주의할 것은 '집에서만 호출할 수 있는'과 '학교에서만 호출할 수 있는'입니다.

만약 이 문장을 '집에(서만 호출할 수) 있는 김 씨'라고 이해했다면 오해의 소지가 있습니다.

집과 학교는 컴퓨터에서 비유하자면 컴퓨터가 참조하는 메모리 주소라고 비유할 수 있기 때문입니다.

public class Main() {
    private void Hello(string name) {
        print("Hello " + name);
    }

    public void Hello(string name) {
        print("Hello " + name);
    }
    
    public static void main(string[] args) {
        Hello("Kim");
        Hello("Kim");
    }
}

위 경우를 보면 쉽게 이해할 수 있습니다.

Hello()의 매개변수가 같을 경우, 우리는 이 두 메서드를 구분할 수 없습니다.

public 메서드 또한 private와 동일한 위치에서 호출될 수 있기 때문입니다.

 


 

2. 오버라이딩 (Overriding)

오버라이딩은 오버로딩과 함께 C# 메서드가 수행할 수 있는 또 다른 다형성 기반의 기능입니다.

오버라이딩은 상속 개념과 연관이 있습니다.

부모 클래스가 가지고 있는 메서드는 자식 클래스도 상속을 통해 호출할 수 있습니다.

public class Weapon : MonoBehaviour {
    protected void Shot() {
        Debug.Log("BANG!");
    }
    
    protected void Reload() {
        Debog.Log("RELOAD");
    }
    
    protected void Aim() {
        Debog.Log("Using Iron Sight");
    }
}

부모 클래스입니다.

Weapon 클래스는 '무기'의 개념을 정의하며, 무기 격발(Shot), 무기 재장전(Reload), 무기 조준(Aim)까지 총 3개의 메서드를 가지고 있습니다.

이 메서드들은 모든 무기에 공통적으로 사용되는 메서드입니다.

어떤 총기이든 간에 일단 조준하고, 쏘고, 재장전하는 행동은 일어나기 때문입니다.

이제 무기 종류에 따라서 개별적인 자식 클래스를 선언하고 Weapon 클래스를 상속한 뒤, 메서드를 호출하면 계층 관계에 따라 코드가 동작할 것입니다.

이와 같은 OOP의 상속 개념은 대표적인 OOP 언어의 특징으로, 실제 세계의 법칙을 묘사했기 때문에 잘 활용한다면 코드의 구조를 쉽게 파악할 수 있어 유지보수 및 개발에 큰 도움이 됩니다.

여기서 오버라이딩은 이 상속 개념에 포함된 '플러그 인'과도 같습니다.

만약, 자식 클래스인 WeaponAR과 WeaponSR을 정의한다고 가정하면, 이 두 자식 클래스가 부모 클래스를 상속하는 것만으로 아무런 문제가 없을까요?

public class Weapon : MonoBehaviour {
    protected void Shot() {
        Debug.Log("BANG!");
    }
    
    protected void Reload() {
        Debog.Log("RELOAD");
    }
    
    protected void Aim() {
        Debog.Log("Using Iron Sight");
    }
}


public class WeaponAR : Weapon {    
    /*
    * 돌격 소총 (AR)
    * - 조정간 자동
    * - 박스형 탄창을 사용하는 재장전
    * - 도트 사이트를 사용하는 정조준
    */

    protected void Shot() {
    }
    
    protected void Reload() {
    }
    
    protected void Aim() {
    }
}


public class WeaponSR : Weapon {    
    /*
    * 저격 소총 (SR)
    * - 조정간 반자동
    * - 박스형 탄창을 사용하는 재장전
    * - 16배율 망원 조준경을 사용하는 정조준
    */

    protected void Shot() {
    }
    
    protected void Reload() {
    }
    
    protected void Aim() {
    }
}

위 주석 내용을 참고하여 WeaponAR 클래스와 Weapon 클래스를 함께 살펴봅시다.

돌격 소총을 정의하는 WeaponAR은 조정간이 자동이므로, 자동 사격이 가능해야 합니다.

또한 기계식 조준기 대신 도트 사이트를 쓰고 있습니다.

즉, 부모 클래스가 사용하는 메서드들을 그대로 쓸 수가 없는 상태입니다.

저격 소총도 상황이 별 다르지 않습니다.

저격 소총은 반자동 사격이 가능하며, 도트 사이트도 아닌 16 배율의 망원 조준경을 쓰고 있습니다.

아무래도 게임 배경이 한반도는 아닌 것 같네요.

이런 상황에서 두 자식 클래스가 부모 클래스를 상속하는 것이 별 의미가 없다고 생각할 수 있지만, 그렇다고 해서 지금의 계층 구조를 포기하기에는 너무 아깝습니다.

뭐가 되었든 간에 저격 소총과 자동 소총 모두 총은 총이기 때문입니다.

이럴 때 오버라이딩을 쓸 수 있습니다.

이 또한 오버로딩과 비유할 때 쓰는 용어가 비슷합니다.

'이름'은 같지만, '알맹이'는 다른 메서드를 만드는 거죠.

단, 이 비유는 상속 개념을 통해 만들어진 부모 클래스와 자식 클래스의 계층 구조가 유효하다는 조건을 배경으로 합니다.

public class Weapon : MonoBehaviour {
    protected void Shot() {
        Debug.Log("BANG!");
    }
    
    protected void Reload() {
        Debog.Log("RELOAD");
    }
    
    protected void Aim() {
        Debog.Log("Using Iron Sight");
    }
}


public class WeaponAR : Weapon {    
    /*
    * 돌격 소총 (AR)
    * - 조정간 자동
    * - 박스형 탄창을 사용하는 재장전
    * - 도트 사이트를 사용하는 정조준
    */

    protected void Shot() {
        Debug.Log("Auto Action: BANG! BANG! BANG!");
    }
    
    protected void Reload() {
        Debog.Log("RELOAD");
    }
    
    protected void Aim() {
        Debog.Log("Using Dot Sight");
    }
}


public class WeaponSR : Weapon {    
    /*
    * 저격 소총 (SR)
    * - 조정간 반자동
    * - 박스형 탄창을 사용하는 재장전
    * - 16배율 망원 조준경을 사용하는 정조준
    */

    protected void Shot() {
        Debug.Log("Semi-Auto Action: BANG! and BANG! and BANG!");
    }
    
    protected void Reload() {
        Debog.Log("RELOAD");
    }
    
    protected void Aim() {
        Debog.Log("Using Telescopic Sight");
    }
}

구현이 끝났습니다.

단순히 자식 클래스가 지니고 있는 메서드의 알맹이를 원하는 데로 구현한 것이 전부입니다.

이처럼 오버라이딩은 부모 클래스가 가지고 있는 메서드를 자식 클래스에서 '개조'하여서 그 알맹이를 다르게 만들어 사용하고 싶을 때 유용합니다.

이 오버라이딩 기능 덕분에 상속을 통해 얻을 수 있는 계층 구조의 장점을 그대로 유지하면서 각자 서로 다른 기능을 구현할 수 있게 하여 코드 재활용 및 유연성을 동시에 잡을 수 있습니다.

이제 메서드를 호출할 때는 각 클래스 타입에 맞게 선언한 객체를 사용하여 호출하면 됩니다.

WeaponAR의 Shot( ) 메서드를 호출하고 싶다면 'WeaonAR 클래스 타입의 객체의 Shot( ) 메서드' 형식으로 호출하는 것입니다.

이를 코드로 표현하면 아래와 같습니다.

WeaponAR ar = new WeaponAR();
ar.Shot();

Unity 엔진에서는 주로 MonoBehaviour 상속해 사용하기 때문에 new 키워드 대신 AddComponent<T>( ) 또는 Instantiate( )를 사용하게 될 것입니다.

물론, 이와 무관하게 원리와 동작 방식은 동일합니다.

 


 

3. global:: 키워드

global:: 키워드는 위에서 살펴본 오버로딩과 오버라이딩을 이해하고 있다면 쉽게 파악할 수 있는 키워드입니다.

using UnityEngine.UI;

public class GameControlDictionary {
    public class Inventory : SerializableDictionary<string, int> { }
    public class Status : SerializableDictionary<GameControlType.Status, float> { }
    public class StatusEffect : SerializableDictionary<GameControlType.StatusEffect, int> { }
    
    [System.Serializable] public class ItemTool : SerializableDictionary<GameControlType.Item, global::ItemTool> { }
    [System.Serializable] public class ItemMaterial : SerializableDictionary<GameControlType.Item, global::ItemMaterial> { }
    [System.Serializable] public class ItemFood : SerializableDictionary<GameControlType.Item, global::ItemFood> { }
    
    [System.Serializable] public class StatusGauge : SerializableDictionary<GameControlType.Status, Slider> { } 

}
using UnityEngine;

public class ItemTool : MonoBehaviour, IItem {
    [field: SerializeField] public GameControlType.Item Type { get; set; }
    [field: SerializeField] public string Name { get; set; }
    
    [field: SerializeField] public float RandomPercent { get; private set; }
    [field: SerializeField] public float RandomWeight { get; private set; }
    [field: SerializeField] public int RandomMaxValue { get; private set; }

    
    public void Init(float value) {
        this.RandomWeight = (this.RandomPercent / value);
    }
}

게임에서 사용되는 아이템들을 직렬화가 가능한 딕셔너리에 담아서 관리하고 싶었습니다.

최종 목표는 Unity 엔진의 Inspector 창에서 ItemTool 딕셔너리의 키와 값을 수정 및 추가하는 것이었습니다.

이를 위해 ItemTool이라는 이름의 클래스를 선언하고, SerializableDictionary 클래스를 상속하도록 했습니다.

그런 다음, ItemTool 클래스는 일종의 타입 형태로 사용할 것이기 때문에 [System.Serializable] 어트리뷰트(Attribute)를 사용해 주었습니다.

그런데 여기서 문제가 생깁니다.

타입으로 사용할 ItemTool 클래스는 SerializableDictionary 클래스를 상속합니다.

SerializableDictionary 클래스는 제네릭 형식이기 때문에 키와 값을 < > 안에 적어주어야 합니다.

키는 상관이 없지만, 값이 문제였죠.

값에 들어가야 하는 아이템 클래스의 이름 또한 ItemTool인 것입니다.

타입의 이름과 딕셔너리에 들어갈 값의 이름이 중복되는 것입니다.

이럴 때 사용되는 것이 global:: 키워드입니다.

이 키워드를 사용하게 되면 해당 지역에서 그 이름을 찾는 것이 아닌, 전역을 기준으로 이름을 찾죠.

이를 통해 딕셔너리의 타입을 정의하기 위해 사용한 클래스의 이름인 ItemTool 타입이 딕셔너리의 값으로 들어가는 것이 아니라 아이템을 정의하는 ItemTool 타입이 딕셔너리 값으로 들어가도록 만들었습니다.

사실 이런 경우에는 아무리 상황을 글로 설명해도 헷갈리는 것이 당연합니다.

'딕셔너리 타입을 정의하기 위해 사용한 클래스, ItemTool'과 '아이템 그 자체를 정의하는 ItemTool'이라는 설명을 아무리 풀어서 적어본들 이름이 같기 때문에 혼란이 사라지지는 않습니다.

오버로딩과 오버라이딩 개념을 학습하며 '동명이인'이라는 추상적인 비유와 '알맹이'라는 추상적인 비유를 어떻게 사용하는지 이해했음에도 말입니다.

그렇기 때문에 어지간하면 global:: 키워드 자체가 등장하지 않도록 사전에 예방하는 것이 좋습니다.

이유는 당연하게도 코드 가독성이 그만큼 떨어지기 때문입니다.

하지만 위의 경우에는 두 개의 ItemTool 이름 중 하나를 억지로 이름을 바꾼다는 게 오히려 더 가독성을 떨어트릴 것이라 판단하여 예외적으로 global:: 키워드를 사용하는 방향을 선택했습니다.

뭐, 나중에 코드를 수정하다가 생각이 바뀐다면 귀찮지만 일괄 수정도 가능하니까요.

 


 


수고하셨습니다!


0 Comments