본문 바로가기
Unity Boot Camp

Udemy STARTERS (유데미 스타터스) Unity 취업 부트캠프 4주차 - C# (virtual/override/sealed, static, 추상화-abstract/interface)

by 개발하는 디토 2022. 7. 15.

상속

팥, 슈크림, 김치, 피자 붕어빵을 만든다고 가정해보자. 4가지 붕어빵을 만드는 틀과 밀가루 반죽은 동일하고, 오로지 속재료만 달라질 것이다. 붕어빵 클래스는 틀의 모양, 반죽, 속재료 맛 등을 속성으로 가진다고 했을 때 이것을 클래스로 구현해보면 다음과 같다.

public class FishBreadBasic
{
    public string shape = "fish"; // 틀의 모양
    public string dough = "wheat"; // 반죽
    public string flavor = "empty"; // 속재료 맛
    public float time = 60f; // 굽는 시간
    
    public float Timer(){
        // 구워야 하는 시간을 반환
        return time;
    }
    public void GetFlavor(){
        Debug.Log("Flavor of this fish bread is "+flavor);
    }
}

FishBreadBasic이라는 붕어빵 클래스를 활용해서 여러 맛의 붕어빵을 만들려면 이제 이 클래스를 상속만 해주면 된다. 

자식 클래스 이름 : 상속 받을 클래스(부모) 이름 을 적어주면 다른 클래스를 상속할 수 있다.

// 붕어빵 클래스를 상속하면 붕어빵 클래스에 있는 모든 attribute, method를 일일이 적어주지 않아도 쓸 수 있다.
public class FB_redbeans : FishBreadBasic
{
    // 팥 붕어빵
}

public class FB_cream : FishBreadBasic
{
    // 슈크림 붕어빵
}

public class FB_kimchi : FishBreadBasic
{
    // 김치 붕어빵
}

public class FB_pizza : FishBreadBasic
{
    // 피자 붕어빵
}

void Start()
{
    // 각 class의 instance 선언
    FB_redbeans fish1 = new FB_redbeans();
    FB_cream fish2 = new FB_cream();
    FB_kimchi fish3 = new FB_kimchi();
    FB_pizza fish4 = new FB_pizza();
    
    // 부모 클래스 FishBreadBasic에 있는 method와 attribute 사용 가능
    fish1.Timer();
    fish1.GetFlavor(); // Flavor of this fish bread is empty 출력
    fish2.GetFlavor(); // Flavor of this fish bread is empty 출력
    fish3.GetFlavor(); // Flavor of this fish bread is empty 출력
    fish4.GetFlavor(); // Flavor of this fish bread is empty 출력
}

Virtual / Override

붕어빵의 맛을 출력하는 GetFlavor() 함수는 자식 클래스의 종류에 따라 다르게 출력되어야 한다.

이때 부모 클래스의 method를 자식 클래스에서 변경, 재정의하려면 부모 클래스의 method에 virtual 키워드를 적어준다.

자식 클래스에서는 override 키워드를 통해 부모의 method를 재정의한다.

public class FishBreadBasic
{

    public string shape = "fish"; // 틀의 모양
    public string dough = "wheat"; // 반죽
    public string flavor = "empty"; // 속재료 맛
    public float time = 60f; // 굽는 시간

    public float Timer()
    {
        // 구워야 하는 시간을 반환
        return time;
    }
    public virtual void GetFlavor()
    {
        //virtual keyword -> 자식 클래스에서 변경 가능
        Debug.Log("Flavor of this fish bread is " + flavor);
    }


    void Start()
    {
        // 각 class의 instance 선언
        FB_redbeans fish1 = new FB_redbeans();
        FB_cream fish2 = new FB_cream();
        FB_kimchi fish3 = new FB_kimchi();
        FB_pizza fish4 = new FB_pizza();

        // 부모 클래스 FishBreadBasic에 있는 method와 attribute 사용 가능
        fish1.Timer();
        fish1.GetFlavor(); // Flavor of this fish bread is empty 출력
        fish2.GetFlavor(); // Flavor of this fish bread is empty 출력
        fish3.GetFlavor(); // Flavor of this fish bread is empty 출력
        fish4.GetFlavor(); // Flavor of this fish bread is empty 출력
    }

}


// 붕어빵 클래스를 상속하면 붕어빵 클래스에 있는 모든 attribute, method를 일일이 적어주지 않아도 쓸 수 있다.
public class FB_redbeans : FishBreadBasic
{
    // 팥 붕어빵
    public override void GetFlavor()
    {
        flavor = "red beans";
        Debug.Log("Flavor of this fish bread is " + flavor);
    }
}

public class FB_cream : FishBreadBasic
{
    // 슈크림 붕어빵
    public override void GetFlavor()
    {
        flavor = "cream";
        Debug.Log("Flavor of this fish bread is " + flavor);
    }
}

public class FB_kimchi : FishBreadBasic
{
    // 김치 붕어빵
    public override void GetFlavor()
    {
        flavor = "kimchi";
        Debug.Log("Flavor of this fish bread is " + flavor);
    }
}

public class FB_pizza : FishBreadBasic
{
    // 피자 붕어빵
    public override void GetFlavor()
    {
        flavor = "kimchi";
        Debug.Log("Flavor of this fish bread is " + flavor);
    }
}

Sealed

sealed는 override와 다르게 자식에 의해 변화되지 않았으면 하는 클래스, method에 붙인다.

sealed class가 되면 상속이 안 되고, sealed method가 되면 자식 클래스에서 같은 이름의 함수를 override 할 수 없다.

//overeride: 부모를 상속하면서 재정의할 때 사용하는 키워드
//자식에 의해 변화되지 않았으면 할 때 sealed 키워드를 사용
public sealed class Seal : MonoBehaviour
{
    
}

//public class SealSon : Seal // Error - Seal을 상속할 수 없음
//{
//}
public class SealSon
{
    public virtual void DoWork()
    {
        
    }
}

public class OvInnerSeal : SealSon // SealSon을 상속한 OvInnerSeal
{
    public sealed override void DoWork()
    {
        // 부모에게 DoWork()를 받아왔으나(override),
        // 자기 자식에게는 상속하길 거부하기 위해 sealed 사용
        
    }
}
public class Ov2InnerSeal : OvInnerSeal
{
    // public override void DoWork()
    // {
    //     Error! 부모의 method 재정의하려고 하니 에러남.
    // }

}

 

Static 

정적 클래스 (static class) / 정적 메서드 (static method)

static을 "정적"으로 번역하는지 몰랐다. 네이버 영어 사전에 따르면 static은 고정된, 정지 상태의 등의 뜻이 있다고 한다. static이 붙는 순간 그 클래스는 프로그램 내부에 하나만 존재할 수 있다.

 

일반 클래스

class는 하위에 attribute(변수)와 method(함수)를 포함하고 있다. 일반적인 class는 다른 class를 만들어 상속도 자유로이 받고 틀에 해당하는 클래스로 붕어빵에 해당하는 instance 여러개를 만들어 쓸 수 있었다.

 

static 클래스

하지만 static class는 프로그램 내부에 하나만 존재할 수 있다.

그래서 static class는 클래스임에도

- 다른 클래스에서 상속 받을 수 없고,

- 인스턴스를 만들 수 없다.

- 또한 static 키워드가 클래스에 적용된 경우 클래스의 모든 구성원은 static이어야 한다. 클래스 내부 attribute, method 모두 static을 붙여서 선언해야 한다는 뜻.

public static class Converter // 프로그램 유일한 클래스
{
    public static double Abs(double d) // static class 내부의 method 역시 유일함. static 붙여줘야 함.
    {
        return d > 0 ? d : -d; // 절댓값 리턴
    }
    
    public static int Floor(double d)
    {
        return (int)d; // 소수점 떼어내고 정수 리턴
    }
}


void Start(){
    // static class는 단일 클래스, Instance 만들 수 없음.
    //Converter c1; // error!
    //Converter c2; // error!
    
    //클래스 이름.method로 접근해 사용하는 것은 가능
    Debug.Log(Converter.Abs(-3.141592));
    Debug.Log(Converter.Floor(1.234));
}

 

클래스는 static class가 아니지만, 클래스 내부 attribute나 method만 static으로 선언할 수도 있다.

일반 클래스에서는 Instance를 만들면 instance.method(), instance.변수명 이렇게 접근했었다.

static으로 선언된 method, attribute 등은 Instance가 아닌 class이름으로 접근하면 사용 가능하다.

 - static method : class이름.method()로 접근
 - static attribute : class이름.변수명으로 접근.

 

public class StaticMethod // 일반 클래스
{
    public static int NumberChange = 4; //static attribute
    public static int Size // Property
    {
        get
        {
            return 15;
        }
    }

    public int GetNumber()
    {
        return NumberChange;
    }

    public static void GetNumberLog()
    {
        Debug.Log(NumberChange);
    }
}

void Start(){
    // StaticMethod의 Instance 선언
    // class 자체는 static이 아니라서 Instance 만들기 가능
    StaticMethod con1 = new StaticMethod();
    StaticMethod con2 = new StaticMethod();
    
    // static attribute이기 때문에 클래스 이름으로 접근함. instance를 통해 접근하지 않음.
    StaticMethod.NumberChange = 10; 
    Debug.Log(StaticMethod.Size); // get property
    
    // static method
    StaticMethod.GetNumberLog();  // Log를 찍는 것은 들어온 것을 찍어주기만 하면 돼서 주로 static으로 만듦.
    
    // static 아닌 method
    con1.GetNumber(); //static method 아니라서 instance 이름으로 접근 가능
    con2.GetNumber();
}

 

죽음의 다이아몬드

죽음의 다이아몬드는 하나의 부모를 2명의 자식이 상속하고, 그 서로 다른 2개의 자식을 하나의 자식이 상속할 때 벌어지는 문제이다.

다음 그림과 같이, Character라는 Class를 Hero와 Villain class가 각각 상속하고, MyCharacter라는 Class가 Hero와 Villain을 모두 상속한다고 해보자.

MyCharacter가 Attack()을 호출할 때 어떤 문장이 출력될까?

이 경우 MyCharacter가 출력하는 문장은 "Hero attack"과 "Villain attack" 중에 어떤 문장일까? 알 수가 없다는 것이 문제이며, 그래서 C#에서는 Class의 다중 상속을 금지하고 있다. 하지만 Interface는 다중 상속이 가능한데 그 이유는 밑에서 알아보자.

 

추상화: Abstract class vs. Interface

추상 클래스 Abstract class

추상 클래스가 뭐지... 싶지만 별 거 아니다. 일종의 설계도, 청사진이다.

다른 여러 클래스에서 공유할 수 있는 공통적인 사항을 담아두기 위한 클래스로 생각하면 된다. 왜 사용하는고 하니 여럿이서 개발을 할 때 여러 클래스를 만들 때 기본적으로 들어가야 할 method나  attribute를 미리 정의해두기 위해 사용한다는 것 같다.

 

- 추상 클래스는 Instance로 만들 수 없다. 대신 추상 클래스를 사용하고 싶은 다른 파생 클래스에서 상속을 받아 사용한다.

- 파생 클래스가 추상 클래스를 상속받으면 다른 클래스는 상속받을 수 없다.

- 추상 클래스 안에서만 추상 method를 정의할 수 있다.

- 추상 method는 추상 클래스를 상속받는 파생 클래스에서 override를 통해 재정의한다.

 

//abstract: 추상 클래스 선언하기 위한 키워드
public abstract class AbstractClass : MonoBehaviour
{
    private string name;
    public AbstractClass(string s)
    {
        Id = s;
    }

    public string Id
    { // property
        get
        {
            return name;
        }
        set
        {
            name = value;
        }
    }
    
    // 설계도로 제공할 함수에 abstract 키워드 달아 제공
    public abstract double CalculateDamage();

    //Error! abstract로 선언했기 때문에 이 함수는 내부를 적어주는 것이 아니라 형식만 제공함.
    //public abstract double CalculateDamage()
    //{
    //    int a = 0; a++; return a;
    //}

    public abstract double CalculateDamageProp
    {
        get;
    }

}
// 추상 클래스 상속
public class Result : AbstractClass
{
    public Result(string id)
        : base(id)
    {
        Debug.Log(id);
    }

    // 추상 클래스는 설계도이기 때문에 추상 클래스 내에서 abstract로 선언된 함수가 상속 받는 자식 클래스에 선언되어 있지 않으면 에러가 남.
    // override해서 재정의해야 함. 

    public override double CalculateDamage()
    {
        return 0.1;
    }
    
    public override double CalculateDamageProp
    {
        get
        {
            Debug.Log("Calculated");
            return 0.1;
        }
    }
}


void Start(){
    // abstract class는 Instance 만들기 불가
    //AbstractClass ac = new AbstractClass("1"); // Error! 
    
    // 다른 클래스에서 상속해서 사용
    Result res = new Result("Capybara");
}

 

추상 클래스에 대해 자세한 건 여기 참고

 

추상 및 봉인 클래스와 클래스 멤버 - C# 프로그래밍 가이드

C#의 추상 키워드를 사용하여 불완전한 클래스와 클래스 멤버를 만듭니다. Sealed 키워드는 이전 가상 클래스 또는 클래스 멤버의 상속을 방지합니다.

docs.microsoft.com

설명을 엄청 잘 해놓으신 분의 블로그도 링크

 

C# 6. 추상클래스, 인터페이스

추상 클래스 (Abstract class) 추상 클래스란 미완성된 클래스를 말합니다. 클래스가 미완성이라는 것은 ...

blog.naver.com

 

 

인터페이스 Interface

추상 클래스처럼 설계도 역할을 하는 것이 Interface인데, 인터페이스는 추상 클래스와 다르게 파생 클래스가 여러 개의 인터페이스를 상속받을 수 있다

 

아래와 같은 Interface가 있다고 해보자. 하나는 Attack, Defense 함수가 있고, 다른 하나는 Attack, Run 함수가 있다.

public interface Interface
{
    public void Attack();
    public void Defense();
}
public interface Interface1
{
    public void Attack(); // Interface에도 Attack이라는 함수 있음
    public void Run();
}

 

Interface와 Interface1을 상속 받아 Character라는 클래스를 만들었다.

이 안에는 Attack 함수가 3종류가 있는데

- Interface에서 가져온 Attack() // 하지만 내용은 인터페이스를 상속 받은 Character class에서 정의함

- Interface1에서 가져온 Attack() // 하지만 내용은 인터페이스를 상속 받은 Character class에서 정의함

- Character 클래스 자체 Attack()

public class Character : MonoBehaviour, Interface, Interface1
{
    void Interface.Attack()
    {
        Debug.Log("IAttack1");
    }
    
    void Interface1.Attack()
    {
        Debug.Log("IAttack2");
    }

    public void Attack()
    {
        Debug.Log("Attack");
    }
    
    public void Run()
    {
        Debug.Log("Run");
    }
    public void Defense()
    {
        Debug.Log("Defense");
    }
}

 

Character charcter = new Character();
        
// charcter에서 직접 Attack 사용
charcter.Attack();


Interface i = new Character();
Interface1 i1 = new Character();

// Interface 통해서 함수 실행
i.Attack();
i1.Attack();

결과는 ? 

Attack

IAttack1

IAttack2

순서로 로그가 찍힐 것이다.

 

Character 클래스 내부에서 정의한 Attack()은 Character의 인스턴스를 만들어 사용하면 되고

Interface, Interface1에서 상속 받아 정의한 Attack()은 Interface i, i1로 Character의 인스턴스를 받아 사용하게 된다는 차이이다. Interface의 경우 각 method가 어떤 부모에게서 왔는지 알 수 있기 때문에 죽음의 다이아몬드 문제가 발생하지 않고, 그래서 다중 상속이 가능하다.

 

 

Namespace

class, 파일을 분리해야 할 때 namesapace를 사용하게 된다.
여러 클래스가 있을 때 이름이 겹칠 수도 있는데 이때 namespace로 구분하면 같은 이름으로 여러개를 만들 수 있다.

using 뒤에오는 것들이 namespace여서

using System;                         =>
using System.Collections;   => 이 녀석들도 다 namespace이다.

/* NamespaceExample.cs */

namespace FirstUnityData
{
    class User
    {
        private int HP;
        private int MP;

        public User(int HP, int MP)
        {
            this.HP = HP;
            this.MP = MP;
        }
    }
}

namespace SecondUnityData
{
    class User
    {
        private int HP;
        private int MP;

        public User(int HP, int MP)
        {
            this.HP = HP;
            this.MP = MP;
        }
    }
}

namespace ThirdUnityData
{
    // namespace 안에 namespace를 또 만든 경우
    // ThirdUnityData.UnityDataOne.User user4 = ~~~~ 이렇게 타고 또 타고 들어가서 접근
    namespace UnityDataOne
    {
        class User
        {
            private int HP;
            private int MP;

            public User(int HP, int MP)
            {
                this.HP = HP;
                this.MP = MP;
            }
        }
    }

    namespace UnityDataTwo
    {
        class User
        {
            private int HP;
            private int MP;

            public User(int HP, int MP)
            {
                this.HP = HP;
                this.MP = MP;
            }
        }
    }
}
/* Main.cs */
using FirstUnityData;
using SecondUnityData;
using ThirdUnityData;

public class Main : MonoBehaviour
{
    void Start()
    {
        //간단히 쓰면 원래는
        //User user = new User(10, 10);

        // 하지만 User class를 여러 class에서 선언했기 때문에 이것을 구분하기 편하기 위해
        // namespace를 설정하고 namespace.class이름으로 접근!    
        FirstUnityData.User user = new FirstUnityData.User(10, 10);
        SecondUnityData.User user2 = new SecondUnityData.User(10, 10);

        // ThirdUnityData namespace 안에 또 다른 namespace 2개가 있기 때문에
        // 바깥namespace.안쪽namespace.class이름 순으로 접근
        ThirdUnityData.UnityDataOne.User user4 = new ThirdUnityData.UnityDataOne.User(10, 10);
    }
}

 

함수 관련 신기한 기능

Deligate

Deligate는 method(함수) 자체를 받는 자료형이다. Deligate를 이용하면 함수를 매개변수처럼 다른 함수에 넘길 수도 있다.

// 선언
deligate 리턴자료형 이름(매개변수자료형 이름);

// deligate 변수 생성
한정자 deligate자료형 변수이름;

// deligate 변수에 함수 담기 / 빼기
deligate변수이름 += 함수이름;
deligate변수이름 -= 함수이름;

 

public class Main : MonoBehaviour
{
    // delegate로 담을 수 있는 변수 형태를 선언하고
    delegate void CustomDelegate1();        // 아무것도 리턴하지 않는 함수 받을 수 있음
    delegate void CustomDelegate2(int num); //숫자 하나를 받는 함수를 받을 수 있음

    // 함수 담을 실제 변수를 만듦
    private CustomDelegate1 _customDelegate1;
    private CustomDelegate2 _customDelegate2;
    
    void Start()
    {
        // 함수 하나만 넣을 때
        _customDelegate1 = One;

        // +를 사용하면 다른 함수도 chain으로 넣을 수 있음. 
        // Button onclick 역시 일종의 delegate. 버튼 눌렀을 때 여러 개의 함수를 실행할 수 있음. chaining을 지원하기 때문.
        _customDelegate1 += Two;
        _customDelegate1 += Three;
        
        // One, Two, Three를 넣었으므로 3개 함수 한 번에 실행
        _customDelegate1(); 
        _customDelegate1?.Invoke(); // 같은 기능인데 ?를 넣어 앞의 변수가 null이면 실행하지 않음
    
        // Say(): integer 하나를 받는 함수
        _customDelegate2 += Say;
    }
    
    void One()
    {
        Debug.Log("1");
    }
    void Two()
    {
        Debug.Log("2");
    }
    void Three()
    {
        Debug.Log("3");
    }
    
    void Say(int n)
    {
        Debug.Log(n);
    }
}

 

?의 사용법

변수?.Invoke(); 
?를 사용해 ? 앞의 instance가 null값인지 아닌지 검사하고 null이면 실행하지 않고 null이 아니면 실행함.

변수.Invoke(); 의 경우 instance가 null일 경우 NullPointerException 에러 발생.

 

Lambda식

함수를 매우 간략하게 적는 람다식! 한번 쓰고 말 함수는 deligate를 활용해 람다식 형태로 사용할 수 있다. deligate로 선언하면 return 자료형, 함수 이름, 매개변수의 자료형을 모두 알고 있기 때문에 생략이 가능해진다.

//선언
delegate변수 = (매개변수이름) => {수행할 내용};
public class Main : MonoBehaviour
{
    // delegate로 담을 수 있는 변수 형태를 선언하고
    delegate void CustomDelegate1();        // 아무것도 리턴하지 않는 함수 받을 수 있음
    delegate void CustomDelegate2(int num); // int를 받아 void 리턴하는 함수
    delegate int CustomDelegate3(int num); // int를 받아 int 리턴하는 함수

    // 함수 담을 실제 변수를 만듦
    private CustomDelegate1 _customDelegate1;
    private CustomDelegate2 _customDelegate2;
    private CustomDelegate3 _customDelegate3;
    
    void Start()
    {
       _customDelegate1 = () => { Debug.Log("AAAAA"); }; // 리턴값, 매개변수 모두 없음
       _customDelegate2 = (num) => { num++ }; // 리턴값 없음, 매개변수 int 하나
       _customDelegate3 = (a) => { return a; }; // 리턴값, 매개변수 모두 하나씩
    }

}

 

예외처리

Unity에서 런타임 도중에 에러가 나면 Console 창에 빨간 경고성 아이콘과 함께 프로그램이 실행을 멈춘다.

그래서 적절한 에러처리가 필요한데, 이는 try-catch문으로 보완할 수 있다.

 

// 사용자 정의 Exception
public class CustomException : Exception // Exception 상속!
{
    public CustomException(string message): base(message) {

        int damage = 0;
        damage = int.MaxValue;

        if (damage > 100)
        {
            damage = 100;
        }
    }
    
}

 

 

후기

초면인 개념을 많이 만났다. class 상속, abstract 만들어 파생 클래스에서 상속, interface 상속...

안 써봤던 개념들인데 앞으로 효율적인 C# 스크립팅을 위해 많이 쓰게될 것 같다.

 

 

 


 

유데미코리아 바로가기

 

Udemy Korea - 실용적인 온라인 강의, 글로벌 전문가에게 배워보세요. | Udemy Korea

유데미코리아 AI, 파이썬, 리엑트, 자바, 노션, 디자인, UI, UIX, 기획 등 전문가의 온라인 강의를 제공하고 있습니다.

www.udemykorea.com

본 포스팅은 유데미-웅진씽크빅 취업 부트캠프 유니티 1기 과정 후기로 작성되었습니다.

댓글