CS/C#

[C#] 인터페이스와 추상 클래스

Mirab 2021. 12. 20. 02:02

객체지향 프로그래밍의 '꽃'인 인터페이스와 추상 클래스에 대해서 공부했습니다.

배우기에 앞서서는 인터페이스와 추상 클래스가 많이 비슷하기도 하고 다른 점이 뭐가 있을까에 대해서 궁금했었는데 빠르게 알아봅시다!

 

# 인터페이스 선언

C#의 인터페이스는 다음과 같이 생겼습니다.

interface flyable
{
    void fly();
}

인터페이스에서는 메서드, 이벤트, 인덱서, 프로퍼티만을 가질 수 있고, 클래스의 선언과 비슷하지만 언뜻 보면 구현부가 없고 함수의 정의 부분만 있습니다.

인터페이스에서는 접근 제한 한정자를 사용할 수 없으며, 모든 것들이 public으로 선언됩니다.

클래스와는 다르게 인스턴스화를 만들 수 도 없고요.

 

다만, 인터페이스를 상속한 클래스에서는 인스턴스를 만드는 것이 가능합니다.

상속받은 클래스에서는 인터페이스에서 선언된 모든 메서드 및 프로퍼티를 구현해줘야하고, 이 메서드들은 public 한정자로 수식해야 합니다.

 

더 나아가면 인터페이스는 분명 인스턴스화를 하지 못하지만 그것을 상속받은 클래스에서는 인스턴스를 만들 수 있고 그것을 인터페이스가 참조할 수 있습니다.

이러한 것이 가능한 이유는 파생 클래스도 기반 클래스와 같은 형식으로 간주된다는 것에서부터 시작됩니다. 인터페이스를 상속받은 클래스의 관계에서도 동일합니다.

 

# 인터페이스는 왜 쓰는 것일까?

저도 항상 인터페이스하면 고개를 끄덕끄덕했지만 정작 왜 사용하는지에 대해서 의문이 많았습니다.

 

인터페이스를 한 마디로 정의하면 약속이라고 합니다.

인터페이스를 가지고 있는 클래스들은 인터페이스에서 정의한 모든 것들을 구현해야 한다는 약속을 가지고 있기 때문입니다.

 

또, 어떤 프로그램을 사용할 때 사용자의 입맛에 따라 결정을 해야한다고 할 때 인터페이스는 아주 훌륭한 해결책이 되기도 합니다.

 

인터페이스를 상속받는 객체는 인터페이스의 역할을 가지고 있기 때문에 인터페이스 명만 봐도 이 클래스가 어떤 지원을 하는지 대략적으로 알 수 도 있습니다.

 

아래에는 어떤 모니터에서 사용자로부터 입력받은 온도를 기록한다고 했을 때, logger가 어떻게 이 메시지를 기록할지 정해주는 방법입니다.

using System;
using System.IO;

namespace Interface
{
    interface ILogger // 인터페이스!
    {
        void WriteLog(string message);
    }

    // 나는 Console에 로그를 저장하겠다.
    class ConsoleLogger : ILogger
    {
        public void WriteLog(string message)
        {
            Console.WriteLine($"{DateTime.Now.ToLocalTime()}, {message}");
        }
    }

    // 나는 파일에 로그를 저장하겠다.
    class FileLogger : ILogger
    {
        private StreamWriter writer;

        public FileLogger(string path)
        {
            writer = File.CreateText(path);
            writer.AutoFlush = true;
        }

        public void WriteLog(string message)
        {
            writer.WriteLine($"{DateTime.Now.ToLocalTime()}, {message}");
        }
    }

    class ClimateMonitor
    {
        private ILogger logger; // 어떤 로거를 사용할 지
        public ClimateMonitor(ILogger logger)
        {
            this.logger = logger;
        }
        public void start()
        {
            while(true)
            {
                Console.WriteLine("온도를 입력 : ");
                string temperature = Console.ReadLine();
                if (temperature == "") break;

                // 등록된 로거에 message를 넘겨준다.
                logger.WriteLog($"현재 온도 : {temperature}");
            }
        }
    }

    class Program
    {
        static void Main()
        {
            ClimateMonitor monitor = new ClimateMonitor(new FileLogger("MyLog.txt"));

            monitor.start();
        }
    }
}

ClimateMonitor는 현재 온도를 기록하려고 하는데 기록하는 방법은 많습니다. 콘솔에 출력하거나 혹은 파일에 저장하거나 등등. 그럴 때 이러한 것을 프로그래머가 입맛에 따라 결정할 수 있게 내부 변수로 인터페이스를 가질 수 있도록 선언하고, 생성자를 통해 어떤 로거를 사용할지 결정한 후 그 로거를 실행하는 방법입니다.

 

중요한 것은 ConsoleLogger와 FileLogger는 모두 ILogger로부터 상속받았기 때문에 ILogger가 두 클래스를 참조할 수 있습니다.

 

어떤가요. 느낌이왔나요?

게임으로 비유하면, 같은 두손검이라도 '쟈드'와 '그륜힐'은 외형과 성능이 다릅니다. 그런데 캐릭터에 착용하면 똑같은 두손검입니다. 따라서 어떤 두손검을 사용할지 캐릭터 내부 변수로 ISward 라는 것을 만들고 캐릭터를 생성할 때 어떤 검을 끼워줄지 매핑하면 됩니다.

 

# 인터페이스도 상속할 수 있다?

인터페이스는 클래스는 물론 구조체도 인터페이스를 상속할 수 있습니다.

그런데 문제점이 발생합니다.

 

기존에 상속받아서 사용하고 있었는데 인터페이스 내부를 수정하려고 한다면? 펑;;;;

왜냐하면 인터페이스를 상속받은 것들은 모두 그 안에 있는 것을 구현해줘야 하기 때문입니다.

 

따라서 기존의 소스 코드에 영향을 주지 않고도 새로운 기능을 추가하기 위해서는 인터페이스를 상속하는 인터페이스를 이용하는 것이 좋다고 합니다. (프로젝트가 작으면 문제없지만, 방대하다면 어느 세월에 고칠까요? 게다가 고친다고 해도 사이드 이펙트는??? => 야근)

 

또 상속하려는 인터페이스가 소스 코드가 아닌 어셈블리만으로 제공되는 경우 우리가 내부 인터페이스를 수정할 수 없는 경우도 있습니다.

 

기존에 ILogger를 상속받는 IFormattableLogger는 다음과 같이 작성됩니다.

interface IFormattableLogger : ILogger
{
    void WriteLog(string format, params object[] args);
}

매개변수로 기존 메시지와, 어떤 인자를 받을지 모르므로 모든 객체의 최상 부모인 object 형식으로 받고 있습니다.

 

직접 확인해봅시다!

using System;

namespace DerivedInterface
{
    // 기존에 있던 인터페이스 입니다.
    interface ILogger
    {
        void WriteLog(string message);
    }

    // 추가 기능이 생겼습니다. (다양한 인자를 받고 싶어요!)
    interface IFormattableLogger : ILogger
    {
        void WriteLog(string format, params Object[] arges);
    }

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

        public void WriteLog(string format, params Object[] args)
        {
            // 지정된 형식에 따라 개체의 값을 문자열로 변환합니다.
            String message = String.Format(format, args);
            Console.WriteLine($"{DateTime.Now.ToLocalTime()}, {message}");
        }
    }

    class Program
    {
        static void Main()
        {
            IFormattableLogger logger = new ConsoleLogger();
            logger.WriteLog("C# is good language");
            logger.WriteLog("{0} + {1} + {2} = {3}", 1, 2, 3, 6);
        }
    }
}

 

# 인터페이스는 다중 상속이 가능해?

C#에서는 여러 클래스를 한꺼번에 상속할 수 없습니다.

어떤 클래스가 A로부터 상속받고, B로부터 상속받았는데 신기하게도 A에도 charge()가 있고, B에도 charge()라는 메서드가 있습니다. 그래서 어떤 charge()를 사용할지 모호하기 때문에 이것을 죽음의 다이아몬드라고 합니다.

 

컴퓨터 세계에서는 모호한 프로그램을 재앙이라고 합니다. 문법의 사소한 한 톨이라도 틀리면 용납하지 않으니까요.

 

그런데 신기하게도 인터페이스는 다중 상속을 지원합니다.

이것도 쓸 수 있고 저것도 쓸 수 있습니다. 이게 가능한 이유는 인터페이스의 경우 외형만 물려주기 때문에 속이 어떨지 몰라도 겉모습만큼은 확실하게 같아야 합니다. 따라서 죽음의 다이아몬드 문제도 생기지 않습니다.

 

다중 상속의 인터페이스 확인해봅시다!

using System;

namespace MultiInterfaceInheritance
{
    interface IRunable
    {
        void Run(); // 나를 받으면 달릴 수 있어
    }

    interface IFlyable
    {
        void Fly(); // 나를 받으면 날 수 있어
    }

    class FlyingOrc : IRunable, IFlyable
    {
        public void Run()
        {
            Console.WriteLine("난 오크 뛴다!");
        }
        public void Fly()
        {
            Console.WriteLine("난 오크 날기도 한다!");
        }
    }

    class Program
    {
        static void Main()
        {
            FlyingOrc orc = new FlyingOrc();
            orc.Run();
            orc.Fly();

            IRunable runnable = orc as IRunable;
            runnable.Run();

            IFlyable flyable = orc as IFlyable;
            flyable.Fly();
        }
    }
}

오크는 뛰기와 날기라는 인터페이스를 모두 가지고 있기 때문에 둘 다 사용할 수 있습니다. 즉 다중 인터페이스 상속이 가능하게 되었습니다. 무서운 오크죠.

 

# 인터페이스의 기본 구현 메서드

기존까지 알고 있었던 인터페이스는 구현부를 만들 수 없었습니다. 그런데? 가능하게 할 수 있습니다.

 

가능하게 하기 전에 왜 필요할 지부터 보겠습니다.

초기 버전을 설계할 때는 이렇게 사용하도록 인터페이스를 정의하고 서비스해왔는데 다시 보니까 이 기능이 빠져있었던 것입니다. 그래서 개발자는 그 인터페이슬 수정하려고 했으나 아차!.. 그것을 상속받아 사용하는 개체가 +99개인 겁니다.. 어떻게 하면 안전하게 추가할 수 있을까요?

 

여기서 등장한 것이 바로 기본 구현 메서드입니다.

인터페이스를 수정했지만 다른 기존 코드에는 아무런 영향을 받지 않습니다.

게다가 인터페이스의 기본 구현 메서드는 인터페이스 참조로 업 캐스팅 했을 때만 사용할 수 있기 때문에 프로그래머가 파생 클래스에서 인터페이스에 추가된 메서드를 엉뚱하게 호출할 일도 없습니다.

 

살펴봅시다.

using System;

namespace DefaultImplemetation
{
    interface ILogger
    {
        void WriteLog(string message);
        void WriteError(string error)
        {
            WriteLog(error); // 구현부가 있네요!
        }
    }

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

    class Program
    {
        static void Main()
        {
            ILogger logger = new ConsoleLogger();
            // 업캐스팅일 때 다 사용할 수 있습니다!
            logger.WriteLog("System Up");
            logger.WriteError("Error!!");

            // 프로그래머가 엉뚱한 호출을 사용을 못하게 막습니다.
            ConsoleLogger clogger = new ConsoleLogger();
            clogger.WriteLog("System Up");
            //clogger.WriteError("난 왜 안돼?");
        }
    }
}

 

# 추상 클래스는 인터페이스와 클래스 사이

추상 클래스는 인터페이스와 다르게 '구현'을 할 수 있습니다.

그렇지만 클래스와는 다르게 인스턴스화 및 인스턴스를 가질 수 없습니다.

=> 구현을 할 수 있지만 인스턴스를 만들지 못합니다.

인터페이스는 모든 메서드가 public이지만 클래스는 default가 private입니다.

추상 클래스는 추상 메서드를 가질 수 있습니다. 이것 때문에 인터페이스와도 유사합니다.

추상 메서드는 구현을 못하지만 파생 클래스에서는 반드시 구현하도록 강제가 됩니다. (인터페이스와 유사)

 

추상 클래스를 이용한 프로그램을 살펴봅시다.

using System;

namespace AbstractClass
{
    abstract class AbstractBase
    {
        protected void PrivateMethodA()
        {
            Console.WriteLine("AbstractBase.PrivateMethodA()");
        }

        public void PublicMethodA()
        {
            Console.WriteLine("PublicMethodA()");
        }

        // 추상 메서드
        public abstract void AbstractMethodA();
    }

    class Derived : AbstractBase
    {
        // 구현하기!
        public override void AbstractMethodA()
        {
            Console.WriteLine("Derived.AbstractMethodA()");
            // 상속받았기 때문에 사용할 수 있습니다.
            PrivateMethodA();
        }
    }

    class Program
    {
        static void Main()
        {
            AbstractBase obj = new Derived();
            obj.AbstractMethodA();
            // 상속받았기 때문에 사용할 수 있습니다.
            obj.PublicMethodA();
        }
    }
}

추상 클래스는 일반 클래스가 가질 수 있는 구현 + 추상 메서드를 가지고 있습니다. (인터페이스 역할)

 

추상 메서드는 추상 클래스를 사용하는 프로그래머가 그 기능을 정의하도록 강제하는 장치이기 때문에 혹여나 실수를 하더라도 컴파일러가 이를 상기시켜줄 수 있습니다. 그래서 추상 클래스를 사용합니다.

 

길고 길었지만,

- 인터페이스를 왜 사용하는지?

- 인터페이스와 클래스의 차이

- 인터페이스와 추상 클래스의 차이

에 대해서 배웠습니다.

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

[C#] Record 와 Property  (0) 2021.12.21
[C#] property, 프로퍼티  (0) 2021.12.21
[C#] 정적 필드와 메서드(static)  (0) 2021.12.12
[C#] 생성자와 소멸자  (0) 2021.12.11
[C#] 클래스  (0) 2021.12.11