본문 바로가기
공부/C#

이것이 C#이다 Chapter 08 인터페이스와 추상 클래스

by bokob 2024. 3. 6.

8.1 인터페이스의 선언

인터페이스(Interface)는 클래스와는 다르게 메소드, 이벤트, 인덱서, 프로퍼티만을 가질 수 있다. 게다가 구현부가 없다.

인터페이스는 접근 한정자를 사용할 수 없고 모든 것이 public으로 선언된다. 인스턴스도 만들 수 없다.

interface 인터페이스_이름
{
    반환_타입 메소드_이름1(매개변수 목록);
    반환_타입 메소드_이름2(매개변수 목록);
    반환_타입 메소드_이름3(매개변수 목록);
    // ...
}
interface ILogger
{
    void WriteLog(string message);
}

 

인터페이스는 인스턴스를 가질 수 없지만, 이 인터페이스를 상속받는 클래스의 인스턴스를 만드는 것은 가능하다.

파생클래스는 인터페이스에 선언된 모든 메소드(및 프로퍼티)를 구현해줘야 하며, public 한정자로 수식해야 한다.

class ConsoleLogger : ILogger
{
    public void WriteLog(string message)
    {
        Console.WriteLine("{0} {1}", DateTime.Now.ToLocalTime(), message);
    }
}
ILogger logger = neew ConsoleLogger();
logger.WriteLog("Hello World!");

인터페이스는 인스턴스는 만들 수 없지만, 참조는 만들 수 있다. 이 참조에 파생 클래스의 객체의 위치를 담는 것이다.

파생 클래스는 기반 클래스와 같은 타입으로 간주된다는 것이 인터페이스와 인터페이스로부터 상속받는 클래스의 관계에도 그대로 적용된다.

 

인터페이스 작명법

C# 프로그래머 사이에서는 인터페이스의 이름 앞에 'I'를 붙이는 것이 관례다.

 

8.2 인터페이스는 약속이다

인터페이스는 클래스가 따라야 하는 약속 역할을 한다. 이 약속은 인터페이스로부터 파생될 클래스가 어떤 메소드를 구현해야 할지를 정의한다.

이전에 예로 들었던 ILogger 인터페이스는 자신으로부터 파생될 클래스가 반드시 WriteLog() 메소드를 구현하도록 강제한다. 어떤 클래스든 ILogger를 상속받아 ILogger에 선언되어 있는 WriteLog() 메소드를 구현하면 ILogger의 역할을 할 수 있다. 앞에서 ILogger 인터페이스를 상속받는 ConsoleLogger 클래스를 선언하여 콘솔에 로그를 출력하도록 WriteLog() 메소드를 구현했는데, 이외에도 얼마든지 ILogger를 상속받는 새로운 클래스를 선언해서 변형할 수 있다.

 

8.3 인터페이스를 상속받는 인터페이스

인터페이스를 상속받을 수 있는 것은 클래스뿐이 아니다. 구조체는 물론이고, 인터페이스도 인터페이스를 상속 받을 수 있다. 기존 인터페이스에 새로운 기능을 추가한 인터페이스를 만들고 싶을 때 인터페이스를 상속 받는 인터페이스를 만들면 된다.

다음 경우처럼 인터페이스를 수정할 수 없을 때는 인터페이스를 상속받는 인터페이스를 이용해야 한다.

  • 상속하려는 인터페이스가 소스 코드가 아닌 어셈블리로 제공되는 경우: .NET SDK에서 제공하는 인터페이스들이 예이다. 어셈블리 안에 있기 때문에 인터페이스를 수정할 수 없다. 이 인터페이스에 새로운 기능을 추가한 인터페이스를 만들고 싶으면 상속하는 수 밖에 없다.
  • 상속하려는 인터페이스의 소스 코드를 갖고 있어도 이미 인터페이스를 상속받은 클래스들이 존재하는 경우:  클래스는 반드시 인터페이스의 '모든' 메소드와 프로퍼티를 구현해야 한다. 인터페이스에 사소한 수정이라도 이루어지면 이 인터페이스를 상속받는 기존 클래스들은 소스 코드를 빌드할 때 컴파일 에러가 발생할 것이다. 기존의 소스 코드에 영향을 주지 않고 새로운 기능을 추가하기 위해서는 인터페이스를 상속받는 인터페이스를 이용하는 편이 좋다.
interface 파생_인터페이스 : 부모_인터페이스
{
    // ... 추가할 메소드 목록
}
interface ILogger
{
    void WriteLog(string message);
}
interface IFormattableLogger : ILogger
{
    void WriteLog(string format, params Object[] args);
}

IFormattableLogger 인터페이스는 ILogger에 선언된 void WriteLog(string message)와 void WriteLog(string format, params Object[] args) 메소드 두 개를 갖게 된다.

 

8.4 여러 인터페이스, 한꺼번에 상속하기

클래스는 여러 클래스를 한꺼번에 상속할 수 없다. 바로 '죽음의 다이아몬드'라는 문제 때문이다.

 

컴파일러라면 어느쪽의 메소드를 물려받아야 할까? 이게 죽음의 다이아몬드 문제의 핵심은 모호성이다.

 

또, 업캐스팅(Up-casting) 문제가 있다. 다중 상속이 가능하면 아래와 같은 코드가 가능하다.

Plane plane = new MyVehicle();
Car   car   = new MyVehicle();

plane에서 Run() 메소드를 실행하면 어떤게 출력될까? 알 수 없다. 어떻게 동작할지 정확하게 예측할 수 없다.

 

위와 같은 문제들 때문에 C#은 클래스의 다중 상속을 허용하지 않는다.

 

인터페이스는 내용이 아닌 외형을 물려준다. 따라서 죽음의 다이아몬드 같은 문제도 발생하지 않는다. 그래서 프로그래머는 여러 인터페이스를 다중 상속받는 클래스를 안심하고 이용할 수 있다.

구현을 물려받는 다중 상속이 있으면 좋을 것이다. 클래스에서 구현된 기능을 얻는 기법에는 상속만 있는 것이 아니다.

상속은 그저 구현을 물려받기 위한 장치가 아닌 다형성을 위한 것이다.

 

여러 클래스로부터 구현을 물려받고 싶을 때

다른 클래스의 기능을 새로운 클래스에 넣는 방법에 상속만 있는 것은 아니다.

포함(Containment)이라는 기법이 있다. 다음과 같이 물려받고 싶은 기능을 가진 클래스들을 필드로 선언해 놓는 것이다.

class MyVehicle
{
    Car car = new Car();
    Plane plane = new Plane();
    
    public void Fly() {plane.Ride();}
    public void Run() {car.Ride();}
}

 

8.5 인터페이스의 기본 구현 메소드

인터페이스에 선언하는 메소드에 구현부가 없었다. 인터페이스가 선언하는 메소드는 파생될 클래스가 무엇을 구현해야 할지를 정의하는 역할만 하면 됐기 때문이다.

기본 구현 메소드는 구현부를 갖는 메소드인데, 인터페이스의 다른 메소드와는 역할이 약간 다르다.

 

interface ILogger
{
    void WriteLog(string message);
}

class ConsoleLogger : ILogger
{
    public void WriteLog(string message)
    {
        Console.WriteLine($"{DateTime.Now.ToLocalTime()}, {message}");
    }
}

ILogger는 평범한 인터페이스이다. ConsoleLogger는 ILogger를 상속받는 평범한 클래스이다.

ILogger에는 WriteLog() 라는 메소드가 선언되어 있기 때문에 ILogger를 상속하는 ConsoleLogger는 이 메소드를 오버라이딩해야 한다.

시간이 지나 ConsoleLogger도 업그레이드 되고 FileLogger와 같은 ILogger의 파생 클래스가 수없이 생겨났다고 가정하자.

이런 코드를 레거시(Legacy, 유산)라고 한다. 레거시 코드는 업그레이드할 때 주의해야 한다.

 

이와 같은 상황에서 초기 버전을 설계할 때 놓친 메소드를 인터페이스에 안전하게 추가할 수 있을까?

다음과 같이 무작정 ILogger에 새 메소드를 추가한다고 해보자.

interface ILogger
{
    void WriteLog(string message);
    void WriteError(string error); // 새로운 메소드 추가
}

 

그 결과, ConsoleLogger를 비롯해서 ILogger를 상속하는 모든 클래스에 대해 다음과 같은 컴파일 에러가 발생한다.

파생 클래스는 인터페이스의 모든 메소드를 구현하는 것이 규칙이기 때문이다.

error CS0535: 'ConsoleLogger'은(는) 'ILogger.WriteError(string)' 인터페이스 멤버를 구현하지 않습니다.

 

기본 구현 메소드는 이런 상황에서 유용하게 사용할 수 있다. 인터페이스 새로운 메소드를 추가할 때 기본적인 구현체를 갖도록 해서 기존에 있는 파생 클래스에서의 컴파일 에러를 막을 수 있다. 다음은 인터페이스 기본 구현 메소드의 예이다.

interface ILogger
{
    void WriteLog(string message);
    void WriteError(string error); // 새로운 메소드 추가
    {
        WriteLog($"Error: {error}"); // WriteError()에 기본 구현을 제공한다.
    }
}

인터페이스를 수정했지만 다른 기존 코드에는 아무런 영향이 없다. 인터페이스의 기본 구현 메소드는 인터페이스 참조로 업캐스팅했을 때만 사용할 수 있다는 점 때문에 프로그래머가 파생 클래스에서 인터페이스에 추가된 메소드를 엉뚱하게 호출할 가능성도 없다. 다음은 그 예시다.

ILogger logger = new ConsoleLogger();
logger.WriteLog("System Up");      // OK
logger.WriteError("System Fail");  // OK

ConsoleLogger clogger = new ConsoleLogger();
clogger.WriteLog("System Up");     // OK
clogger.WriteError("System Fail"); // 컴파일 에러

ConsoleLogger가 WriteError()를 오버라이딩하지 않기 때문이다. 즉, 인터페이스에 선언된 기본 구현 인터페이스는 파생 클래스의 참조로 호출할 수 없다.

 

8.6 추상 클래스: 인터페이스와 클래스 사이

추상 클래스는 '구현'을 가질 수 있다. 하지만 클래스와는 달리 인스턴스를 가질 수는 없다.

추상 클래스는 abstract 한정자와 class 키워드를 이용해서 선언한다.

abstract class 클래스_이름
{
    // 클래스와 동일하게 구현
}

추상 클래스가 인터페이스와 클래스의 중간에 있다고 하지만, 사실 클래스에 더 가깝다.

추상 클래스의 접근성이 그 예이다. 인터페이스에서는 모든 메소드가 public으로 선언되지만, 클래스는 한정자를 명시하지 않으면 모든 메소드가 private으로 선언된다.

추상 클래스에는 인스턴스를 만들 수 없다는 점 외에도 클래스와 다른 점이 또 하나 있다. 바로 추상 메소드(Abstract Method)를 가질 수 있다는 것이다. 추상 메소드는 추상 클래스가 한 편으로 인터페이스의 역할을 할 수 있게 해주는 장치다. 구현을 갖지 못하지만 파생 클래스에서 반드시 구현하도록 강제한다.

다시 말해, 추상 클래스를 상속하는 클래스들이 반드시 이 메소드를 갖고 있을 거라는 '약속'인 것이다.

추상 메소드의 기본 접근성은 어떻게 될까?

추상 클래스나 클래스는 그 안에서 선언되는 모든 필드, 메소드, 프로퍼티, 이벤트 모두 접근 한정자를 명시하지 않으면 private이다.

여기서 추상 메소드도 예외가 될 수는 없다. 하지만, 파생 클래스에서 반드시 구현해야 하는 추상 메소드를 private로 둘 수는 없다. 그래서 C# 컴파일러는 추상 메소드가 반드시 public, protected, internal, protected internal 한정자 중 하나로 수식될 것을 강요한다. 이렇게 하면 클래스의 접근성 원칙도, 인터페이스의 접근성 원칙도 지켜질 수 있다.

abstract class AbstractBase
{
    public abstract void SomeMethod();
}

class Derived : AbstractBase
{
    public override void SomeMethod()
    {
        // Somethig
    }
}

 

추상 클래스가 또 다른 추상 클래스를 상속하는 경우

추상 클래스는 또 다른 추상 클래스를 상속받을 수 있으며, 이 경우 자식 추상 클래스는 부모 추상 클래스의 추상 메소드를 구현하지 않아도 된다. 추상 메소드는 인스턴스를 생성할 클래스에서 구현하면 되기 때문이다.

 

추상 클래스는 일반 클래스가 가질 수 있는 구현과 더불어 추상 메소드를 가지고 있다.

추상 메소드는 추상 클래스를 사용하는 프로그래머가 그 기능을 정의하도록 강제하는 장치다.

똑같은 일을 보통의 클래스를 통해서도 할 수 있다. 메소드를 선언한 다음, 클래스에 대한 메뉴얼을 작성해서 코드와 함께 배포한다. "이 클래스는 직접 인스턴스화하지 말고 파생 클래스를 만들어 사용하시오.  MethodA(). MethodB()를 오버라이딩해야 한다." 라는 식으로 말이다. 그러나 이를 프로그래머가 준수하도록 강제할 수 없다.

하지만 추상 클래스를 사용하면 이러한 설명을 할 필요가 없다. 추상 클래스와 추상 메소드 자체가 이런 설명을 담고 있는 것이기 때문이다. 이것이 추상 클래스를 사용하는 이유이다.