본문 바로가기

C#

C# - Custom Type2

상속

코드를 재사용하고, 기존 클래스를 확장하고 커스터마이징하는 장점이 있습니다.

 

다형성(Polymorphism)

Base Type이 Derived Type의 객체를 참조할 수 있다는 뜻입니다.

참조타입 & 실타입

 

가상 함수 멤버 (virtual)

virtual 키워드를 지정해서 선언한 메서드는 가상 함수 멤버 또는 가상 멤버라고 부릅니다.

파생 클래스는 기반 클래스의 가상 함수 멤버를 재정의(override)함으로써 좀 더 특화된 구현을 제공할 수 있습니다.

virtual을 적용할 수 있는 멤버는 메서드, 속성, 인덱서, 이벤트입니다.

 

파생 클래스에서 가상 멤버를 재정의할 때에는 반드시 override 수정자를 지정해야 합니다.

그리고 서명뿐만 아니라 반환 형식과 접근 수준도 동일해야합니다.

파생 클래스의 재정의 멤버에서 기반 클래스의 구현을 호출해야할 때에는 base키워드를 사용하면 됩니다.

 

추상 클래스와 추상 멤버 (abstract)

abstract 클래스는 인스턴스를 생성할 수 없습니다. 반대는 'concrete 클래스'라고 부릅니다.

추상 클래스에서는 abstract 멤버를 정의할 수 있습니다.

virtual 멤버와 비슷하지만 구현을 제공하지 않는다는 점에서 다릅니다. 파생 클래스에서 반드시 구현을 제공해야 합니다.

단, 파생 클래스 역시 추상 클래스라면 구현을 제공하지 않아도 됩니다.

 

상속된 멤버 숨기기 (new)

기반 클래스와 파생 클래스에 동일한 멤버가 존재할 수 있습니다.

public class A { public int Counter = 1; }

public class B : A { public new int Counter = 2; }

 

하지만 보통 프로그래머가 일부러 이런 식으로 코딩하는 경우는 별로 없습니다.

파생형식에 어떤 멤버를 추가한 후에 그와 동일한 멤버를 기반 형식에 추가해서 생기는 실수입니다.

그러면 컴파일러는 경고를 발생시키고, 참조형식에 따라 행동합니다.

 

Counter에 접근하려는 참조가 A에 대한 참조이면 A.Counter에 접근합니다.

Counter에 접근하려는 참조가 B에 대한 참조이면 B.Counter에 접근합니다.

 

그런데 프로그래머가 의도적으로 멤버를 가리려는 경우도 없지는 않습니다.

그럴때 new 키워드의 새로운 쓰임새가 쓰입니다.

단지 컴파일러의 경고를 없애고 다른 프로그래머에게 이는 의도적임을 드러내는 역할을 합니다.

 

new vs override

한가지 예시로 모든게 설명됩니다.

 

봉인 클래스와 봉인 멤버(sealed)

sealed라는 키워드에 대한 내용입니다.

메서드의 경우에는 virtual이라는 키워드로 내리 상속을 계속 받고있는 관계를 중간에 sealed로 막아 상속을 받아 override 할 수 없게 봉인합니다.

클래스의 경우에는 더 이상 해당 클래스의 모든 가상 함수들을 봉인합니다.

 

base 키워드

두가지 용도로 사용됩니다.

  • 파생 클래스의 재정의 멤버에서 기반 클래스의 가상 멤버에 접근할 때
  • 기반 클래스의 생성자를 호출할 때

첫번째 경우를 비가상(nonvirtual)이라고 합니다.

실행 시점에 인스턴스의 실제 타입과는 무관하게 항상 기반 클래스의 구현에 접근하게 된다는 뜻입니다.

접근하려는 것이 override되지않고 new로 가려진 멤버일지라도 base를 이용하여 기반 구현에 접근이 가능합니다.

 

생성자와 상속

두번째 경우는 다음과 같습니다.

 		class BaseClass 
    {
        public int x;
        public BaseClass() { }
        public BaseClass(int x) { this.x = x; }
    }
    class DerivedClass : BaseClass
    { 
    }
    
    class Program
    {
        static void Main(string[] args)
        {
            DerivedClass dc = new DerivedClass(123); // wrong

        }
    }

위와 같이 하게 되면 오류가 납니다.

파생 클래스는 기본 생성자 밖에 없고 정수 한개를 매개변수로 받는 기반 클래스를 호출하기 위해서는 파생클래스에 생성자를 선언하여

그 생성자에서 base키워드를 사용해 기반 클래스의 생성자를 호출하여야 합니다.

public DerivedClass(int x) : base(x) { }

DerivedClass 클래스를 이렇게 수정

 

매개변수 없는 기반 클래스 생성자의 암묵적 호출

파생 클래스의 생성자에 base 키워드가 없으면 기반 형식의 매개변수 없는 생성자가 암묵적으로 호출됩니다.

기반 클래스에 접근 가능한 기본 생성자가 하나도 없으면 파생 클래스는 생성자에 반드시 base키워드를 사용해야 합니다.

public class BaseClass
{
	public int X;
	public BaseClass() { X = 1; }
}

public class SubClass : BaseClass
{
	public SubClass() { Console.Write(X); } // 1
}

 

생성자와 필드 초기화 순서

객체가 인스턴스화될 때 초기화는 다음과 같은 순서로 일어납니다.

 

  1. 파생 클래스에서 기반 클래스순으로:
  2. a. 필드들이 초기화된다
  3. b. 기반 클래스 생성자 호출들의 인수들이 평가된다.
  4. 기반 클래스에서 파생 클래스순으로
  5. a. 생성자 본문들이 실행된다.

중복적재와 해소

상속은 메서드 중복적재에 흥미로운 영향을 미칩니다.

Foo라는 함수에 다음과 같은 두가지 중복적재 버전이 있다고합시다.

static void Foo(Asset a) {}
static void Foo(House h) {}

중복적재된 함수가 호출되면 가장 구체적인 형식을 가진 버전이 선택됩니다.

House h = new House();
Foo(h); // Foo(House)가 호출됨

실제로 호출할 구체적인 버전은 정적으로 결정됩니다.

다른 말로 하면 중복적재는 실행시점이 아니라 컴파일 시점에 해소(resolution)됩니다.

또 다른말로 하면 실행중에 a에 어떤 실형식이 들어갈지 모르기 때문에 참조형식으로 결정합니다.

 

다음 코드에서 a의 실행시점 형식이 House이긴 하지만, 호출되는 것은 Foo(Asset)입니다.

Asset a = new House();
Foo(a); // Foo(Asset)이 호출됨

 

Assetdynamic으로 캐스팅하면 중복적재 해소에 관한 결정은 실행시점으로 미뤄집니다. 실행 시점에서는 객체의 실제 형식에 기초하여 중복적재가 해소됩니다.

Asset a = new House();

Foo((dynamic)a); // Calls Foo(House)

 

정적 및 실행시점 형식 점검

C#은 형식을 컴파일 시점(정적)에서 검사하고 실행 시점에서 다시 점검(CLR이 점검)합니다.

int x = "5";

위의 코드는 정적 검사에서 컴파일오류가 날 것입니다.

 

실행시점 점검은 CLR이 참조 변환이나 하향 캐스팅, 언박싱을 검사하는 것입니다.

object y = "5";
int z = (int)y; // 실행시점 오류; 하향 캐스팅 실패 해

실행 시점 점검이 가능한 이유는 힙에 할당된 모든 객체에 작은 형식 토큰이 존재한다고 합니다.

그 형식 토큰은 object객체의 GetType() 메서드로 얻을 수 있습니다.

 

GetType메서드 vs typeof 연산자

C#의 모든 타입은 실행 시점에 System.Type의 인스턴스로 표현됩니다.

두가지 다 System.Type의 객체를 얻을 수 있는데

GetType인스턴스에 대해 호출하고, 런타임에 평가된다.

typeof형식 이름에 대해 호출합니다. 컴파일타임에 평가된다.

 

ToString 메서드

C#의 모든 형식들은 ToString 메서드를 상속받습니다.

하지만 override할 수 있습니다.

		class BaseClass 
    {
        public int x;

        public override string ToString() => x.ToString();

    }

 

구조체

구조체(struct)는 클래스와 비슷하나, 다음과 같은 중요한 차이가 있습니다.

  • 구조체는 값 형식이고 클래스는 참조 형식입니다.
  • 구조체는 상속을 지원하지 않습니다. (object , 좀더 정확하게는 System.ValueType을 암묵적으로 상속한다는 점만 제외하면).

* 하지만 C++은 struct도 상속을 지원합니다. 그리고 C++에서의 struct와 class의 차이는 기본접근수준뿐입니다.

 

구조체는 클래스가 지원하는 요소 중 다음을 제외한 모든 요소를 지원합니다.

  • 매개변수 없는 생성자
  • 필드 초기화
  • 종료자
  • 가상 멤버와 보호된 멤버

값 형식 의미론이 바람직한 경우에 클래스보다는 구조체를 사용하는 편이 적합한 경우가 있습니다.

예를 들면 커스텀 수치 형식(Vector 등등)에서는 배정 시 참조가 아니라 값을 복사하는 것이 더 자연스러우므로 구조체가 더 적합합니다.

또 구조체는 값 형식이므로 인스턴스화 시 힙에 객체를 할당할 필요가 없습니다.

덕분에 한 형식의 인스턴스를 많이 생성할 때에는 구조체가 더 효율적일 수 있습니다.

예를 들면 값 형식의 배열을 생성하는 경우 힙 할당을 한 번만 수행하면 됩니다.

 

구조체의 생성 의미론

구조체의 생성 의미론은 다음과 같습니다.

  • 구조체에는 매개변수 없는 생성자가 암묵적으로 존재하며, 이를 프로그래머가 직접 재정의 할 수는 없습니다. 이 생성자는 구조체 필드들의 모든 비트를 0으로 초기화합니다.
  • 구조체의 생성자를 프로그래머가 직접 정의하는 경우, 생성자에서 모든 필드를 명시적으로 배정해야 합니다.

 

인터페이스

클래스와 비슷하나 구현 대신 선언만 들어 있습니다.

다음은 인터페이스의 특징들입니다.

  • 인터페이스의 멤버들은 암묵적으로 전부 abstract 멤버입니다.
  • 하나의 클래스는 여러 인터페이스를 상속 받을 수 있습니다.

인터페이스가 가질 수 있는 멤버들은 메서드, 속성(프로퍼티), 이벤트, 인덱서뿐입니다. 이는 클래스에서 abstract 멤버가 될 수 있는 멤버들과 정확히 일치합니다.

그리고 인터페이스의 멤버들은 항상 암묵적으로 public입니다. 인터페이스를 구현한다는 것은 인터페이스의 모든 멤버에 대해 각각 하나의 public 구현을 제공한다는 것을 의미합니다.

 

public interface IEnumerator
{
	bool MoveNext();
	object Current { get; }
	void Reset();
}

 

인터페이스 확장

인터페이스는 또 다른 인터페이스를 상속받을 수 있습니다.

public interface IUndoable { void Undo(); }
public interface IRedoable : IUndoable { void Redo(); }

 

명시적 인터페이스 구현

한 클래스가 여러 개의 인터페이스를 구현하다 보면 멤버 서명이 충돌할 수 있습니다.

그런 충돌을 해소하는 방법은 특정 인터페이스의 멤버를 명시적으로 구현하는 것입니다.

interface I1 { void Foo(); }
interface I2 { int Foo(); }
public class Widget : I1, I2
{
	public void Foo()
	{
		//...
	}

	public int **I2.Foo()**
	{
		//...
	}
}

* **I2.Foo()**는 Bold 상태여서 **이 생김

 

I1의 Foo와 I2의 Foo메서드는 서명이 같습니다.

하지만 I2.Foo()를 명시적으로 구현한 덕분에 공존할 수 있습니다.

대신 구현한 멤버를 호출할 때는 해당 인터페이스로의 명시적 캐스팅이 꼭 필요합니다.

Widget w = new Widget();
w.Foo()       // Widget의 I1.Foo()
((I1)w).Foo() // Widget의 I1.Foo()
((I2)w).Foo() // Widget의 I2.Foo()

인터페이스 멤버를 명시적으로 구현하는 또 다른 목적은

인터페이스 멤버를 숨기는 것입니다.

명시적 캐스팅을 할 때만 호출할 수 있으니 그런 효과도 나옵니다.

 

인터페이스 멤버의 가상 구현

암묵적으로 구현된 인터페이스 멤버들은 기본적으로 sealed 키워드를 달고 있습니다.

그래서 인터페이스 멤버를 이 클래스 아래의 파생 클래스로 재정의하고 싶다고 한다면

명시적으로 virtual키워드나 abstract키워드를 붙여줘야합니다.

public interface IUndoable { void Undo(); }
public class TextBox : IUndoable
{
	public virtual void Undo() => Console.WriteLine("TextBox.Undo()");
}
public class RichTexxtBox : TextBox
{
	public override void Undo() => Console.WriteLine("RichTextBox.Undo()");
}

 

파생 클래스에서 인터페이스를 재구현

파생 클래스는 기반 클래스에서 이미 구현한 임의의 인터페이스 멤버를 다시 구현할 수 있습니다.

이러한 재구현은 기존 멤버 구현을 '하이재킹'하는 것과 같습니다.

기반 클래스에서 해당 멤버가 virtual로 선언되어 있든 아니든 언제나 가능합니다.

RichTextBox.Undo()
TextBox.Undo()
RichTextBox.Undo()

 

 

아래와 같이 하면 형식 체계를 깨뜨리는 것입니다.

using System;

namespace csharp_test
{
    public interface IUndoable { void Undo(); }
    public class TextBox : **IUndoable**
    {
        public void Undo()
        {
            Console.WriteLine("TextBox.Undo()");
        }
    }

    public class RichTextBox : TextBox, **IUndoable**
    { 
        public void Undo()
        {
            Console.WriteLine("RichTextBox.Undo()");
        }
    }

    class Program
    {

        static void Main()
        {
            RichTextBox rTextBox = new RichTextBox();

            rTextBox.Undo();
            ((TextBox)rTextBox).Undo();
            ((IUndoable)rTextBox).Undo();
        }
    }
}

 

그러므로 명시적으로 구현된 인터페이스 멤버를 재정의하는 수단으로 사용하는 것이 가장 적합합니다.

using System;

namespace csharp_test
{
    public interface IUndoable { void Undo(); }
    public class TextBox : **IUndoable**
    {
        **void IUndoable.Undo()**
        {
            Console.WriteLine("TextBox.Undo()");
        }
    }

    public class RichTextBox : TextBox, **IUndoable**
    { 
        public void Undo()
        {
            Console.WriteLine("RichTextBox.Undo()");
        }
    }

    class Program
    {

        static void Main()
        {
            RichTextBox rTextBox = new RichTextBox();

            rTextBox.Undo();
            **//((TextBox)rTextBox).Undo(); // 이제 이 부분은 컴파일에러**
            ((IUndoable)rTextBox).Undo();

        }
    }
}

 

인터페이스와 박싱

구조체를 인터페이스로 변환하면 박싱이 발생합니다.

그러나 암묵적으로 구현된 멤버를 구조체에 대해 호출하면 박싱은 일어나지 않습니다.

 

using System;

namespace csharp_test
{
    public interface I { void Foo(); }
    struct S : I { public void Foo() { } }

    class Program
    {

        static void Main()
        {
            S s = new S();
            **s.Foo(); // 박싱 없음**

            **I i = s; // 박싱 발생**
            i.Foo();
        }
    }
}

 

클래스 vs 인터페이스

다음은 언제 클래스를 사용하고 언제 인터페이스를 사용할지에 대한 일반적인 지침입니다.

  • 구현을 공유하는 것이 자연스러운 형식들에는 클래스와 파생 클래스를 사용합니다.
  • 구현이 각자 독립적인 형식들에 대해서는 인터페이스를 사용합니다.
abstract class Animal {}
abstract class Bird : Animal {}
abstract class Insect : Animal {}
abstract class FlyingCreature : Animal {}
abstract class Carnivore : Animal {}

class Ostruch : Bird {]
class Eagle : Bird, FlyingCreature, Carnivore {} // 다중 상속 불가능 -> 컴파일에러
class Bee : Insect, FlyingCreature {} // 다중 상속 불가능 -> 컴파일에러
class Flea : Insect, Carnivore {} // 다중 상속 불가능 -> 컴파일에러

Eagle, Bee, Flea 클래스는 컴파일되지 않습니다.

여러 개의 클래스를 상속하는 것은 C#에서는 불가능하기 때문입니다.

이를 해결하기 위해서는 일부 형식들을 인터페이스로 바꾸어야 합니다.

 

일반적인 지침으로 생각해보자면,

곤충들은 클래스로 남겨두는 것이 좋습니다.

반면 FlyingCreature와 Carnivore 는 구현에 있어서 독자적인 매커니즘이 있을 것입니다.

그래서 둘은 인터페이스로 빼는 편이 좋습니다.

728x90

'C#' 카테고리의 다른 글

C# - Coroutine  (0) 2024.07.06
C# - 2  (0) 2024.05.19
Virtual Table  (0) 2024.04.11
Class  (0) 2024.04.09
C#  (0) 2024.04.09