본문 바로가기
ZAION/C#

[이것이 C#이다]Ch.08 인터페이스와 추상 클래스

by 우기37 2024. 1. 22.

## 목차

1) 인터페이스의 선언

2) 인터페이스는 약속이다

3) 인터페이스를 상속하는 인터페이스

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

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

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

 

 

 

*인프런 강의를 듣고 추상클래스와 인터페이스에 대해서 좀 더 깊게 그리고 더욱 이해하기 위해서 '이것이 C#이다' 책을 공부하면서 정리해보았습니다.

#1 인터페이스의 선언

interface ILogger
{
	void WriteLog(string message);
}

 

인터페이스는 위와 같이 interface 인터페이스_이름 그리고 반환_형식 메소드_이름(매개변수_목록)을 안에 선언하여 생성 할 수 있습니다.

또한 개발자들 사이에서 이것은 인터페이스다라는 것을 구분하기 위해 인터페이스이름 앞에 "I"를 붙여 쓰는 관례가 있습니다.(C#에서 정한 규칙은 아니여서 "I"를 안붙여도 상관은 없습니다.)

 

클래스를 선언하는 것과 비슷해 보이지만, 메소드, 이벤트, 인덱서, 프로퍼티만을 가질 수 있다는 차이점이 있습니다.

그리고 구현부가 없고, 인터페이스는 접근 제한 한정자를 사용할 수 없고 모든 것이 public으로 선언됩니다. 또한, 인스턴스도 만들 수가 없습니다.

 

그렇다면 왜? 인터페이스를 사용하는 것인가??

이 인터페이스를 상속받는 클래스의 인스턴스를 만드는 것은 가능합니다. 이에 규칙이 있는데, 파생 클래스는 인터페이스에 선언된 모든 메소드(및 프로퍼티)를 구현해줘야 하며, 이 메소드들은 public 한정자로 수식해야 합니다.

 

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

 

이와 같이 선언한 클래스는 다음과 같이 인스턴스화가 가능합니다.

 

ILogger logger = new ConsoleLogger();
logger.WriteLog("Hello, World!");

 

위 코드처럼 인터페이스는 인스턴스를 못 만들지만, 참조는 만들 수 있씁니다.이 참조에 파생 클래스의 객체의 위치를 담는 것입니다.

 

이것은 인터페이스로부터 상속받는 클래스의 관계에도 그대로 적용됩니다. 즉, ConsoleLogger의 객체는 OLogger의 객체로 취급할 수 있다는 이야기입니다.

 

 

 

 

#2 인터페이스는 약속이다

USB 포트는 저장 장치, 입력 장치, 선풍기 등 다양하게 사용 할 수 있습니다. 그 이유는 PC와 주변기기가 USB라는 약속을 따르기 때문입니다.

 

인터페이스도 소프트웨어 내에서 USB와 같은 역할을 합니다. 이 약속은 인터페이스로부터 파생될 클래스가 어떤 메소드를 구현해야 할지를 정의 합니다.

 

이미지 출처 : https://www.baslerweb.com/ko-kr/shop/usb-3-0-interface-card-pcie-fresco-fl1100-4hc-x4-4ports/

 

 

[실습]

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ThisIsCSharpExam
{
    interface ILogger
    {
        void WriteLog(string message);
    }
    class ConsoleLogger : ILogger
    {
        public void WriteLog(string message)
        {
            Console.WriteLine("{0} {1}", 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("{0} {1}", DateTime.Now.ToLocalTime(), message);
        }
    }
    class ClimateMonitor
    {
        private ILogger logger;
        public ClimateMonitor(ILogger logger)
        {
            this.logger = logger;
        }
        public void start()
        {
            while (true)
            {
                Console.Write("온도를 입력해주세요 : ");
                string temperature = Console.ReadLine();
                if (temperature == "")
                    break;

                logger.WriteLog("현재 온도 : " + temperature);
            }
        }
    }
    class MainApp
    {
        static void Main(string[] args)
        {
            ClimateMonitor monitor = new ClimateMonitor(new  ConsoleLogger());  // ConsoleLogger 객체를 가리킴
            //ClimateMonitor monitor = new ClimateMonitor(new FileLogger("C:\\Users\\USER\\Desktop\\CSharp\\StudyCSharp\\ThisIsCSharpExam\\MyLog.txt"));    // FileLogger 객체를 가리킴
            monitor.start();
        }
    }
}

 

 

FileLogger)

 

ConsoleLogger)

 

 

 

#3 인터페이스를 상속하는 인터페이스

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

 

기존 인터페이스를 그냥 수정하면 되지 않을까? 라고 생각할 수 있지만 다음 경우처럼 인터페이스를 수정할 수 없을 때는 인터페이스를 상속하는 인터페이스를 이용해야 합니다.

 

1. 상속하려는 인터페이스가 소스 코드가 아닌 어셈블리로만 제공되는 경우 : .NET SDK에서 제공하는 인터페이스들이 그 예입니다. 어셈블리 안에 있기 때문에 인터페이스를 수정할 수 없습니다. 

 

2. 상속하려는 인터페이스의 소스 코드를 갖고 있어도 이미 인터페이스를 상속하는 클래스들이 존재하는 경우 : 클래스는 반드시 인터페이스의 '모든' 메소드와 프로퍼티를 구현해야 합니다.

 

인터페이스가 인터페이스를 상속하기 위해 사용하는 문법은 클래스이 문법과 똑같습니다.

 

[실습]

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;

namespace ThisIsCSharpExam
{
    interface ILogger2
    {
        void WriteLog(string message);
    }
    interface IFormattableLogger : ILogger2
    {
        void WriteLog(string format, params object[] args);
    }
    class ConsoleLogger2 : IFormattableLogger
    {
        public void WriteLog(string message) {
            Console.WriteLine("{0} {1}", DateTime.Now.ToLocalTime(), message);
        }
        public void WriteLog(string format, params Object[] args)
        {
            String message = String.Format(format, args);
            Console.WriteLine("{0} {1}", DateTime.Now.ToLocalTime(), message);
        }
    }
    class MainApp
    {
        static void Main(string[] args)
        {
            IFormattableLogger logger = new ConsoleLogger2();
            logger.WriteLog("The World is not flat.");
            logger.WriteLog("{0} + {1} = {2}", 1, 1, 2);
        }
    }
}

 

 

 

 

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

클래스는 여러 클래스를 한꺼번에 상속할 수 없습니다. 이른바 '죽음의 다이아몬드'라는 문제 때문입니다. 죽음의 다이아몬드란, 최초의 클래스(Ridable)가 두 개의 파생 클래스(Car, Plane)로부터 상속받고, 이 두 개의 파생 클래스를 다시 하나의 클래스(MyVehicle)가 상속하는 것을 말합니다.

 

이처럼 컴파일러가 어느 클래스의 Ride()를 선언 할지 모호하여 알 수가 없습니다.

그래서 C#은 클래스의 다중 상속을 허용하지 않습니다.

 

그렇다면 인터페이스는?

인터페이스는 내용이 아닌 외형을 물려줍니다. 속은 어떨지 몰라도 겉모습만큼은 정확하게 자신을 닮기를 강제합니다. 그래서 프로그래머는 죽음의 다이아몬드 같은 문제 없이 여러 인터페이스를 다중 상속하는 클래스를 안심하고 이용할 수 있습니다.

 

[실습]

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ThisIsCSharpExam
{
    interface IRunnable
    {
        void Run();
    }
    interface IFlyable
    {
        void Fly();
    }
    class FlyingCar : IRunnable, IFlyable
    {
        public void Run()
        {
            Console.WriteLine("Run!, Run!");
        }
        public void Fly()
        {
            Console.WriteLine("Fly! Fly!");
        }
    }
    class MainApp
    {
        static void Main(string[] args)
        {
        FlyingCar car = new FlyingCar();
        car.Run();
        car.Fly();

        IRunnable runnable = car as IRunnable;
        runnable.Run();

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

 

 

 

 

 

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

인터페이스에 선언하는 메소드에 구현부가 지금까지는 없었습니다.

인터페이스가 선언하는 메소드는 파생될 클래스가 무엇을 구현해야 할지를 정의하는 역할만 하면 됐기 때문입니다. 기본 구현 메소드는 이름처럼 구현부를 가지는 메소드인데, 인터페이스의 다른 메소드와는 역할이 약간 다릅니다.

 

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

 

ILogger에는 WriteLog()라는 메소드가 선언되어 있기 때문에 ILogger를 상속하는 ConsoleLogger는 이 메소드를 오버라이딩해야 합니다. 여기까지는 이해하고 있는 내용입니다. 이제 이 코드에 무구한 역사가 더해져 ConsoleLogger도 업드레이드됐을 뿐 아니라, FileLogger와 같은 ILogger의 파생 클래스가 수없이 생겼다고 가정해보겠습니다.

 

이런 코드를 레거시(Legacy: 유산)이라고 하는데, 레거시 코드는 업그레이드 할 때 각별히 주의해야 합니다.

 

이와 같은 상황에서 초기 버전을 설계할 때 놓친 메소드를 인터페이스에 안전하게 추가할 수 있을까요? 다음과 같이 무작정 ILogger에 새 메소드를 추가한다고 해보겠습니다.

 

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

 

그 결과, ConsoleLogger를 비롯해서 ILoggger를 상속하는 모든 크래스에 대해 다음과 같은 컴파일 에러가 발생합니다. 파생 클래스는인터페이스의 모든 메소드를 구현하는 것이 기본 규칙이 때문입니다.

 

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

 

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

 

[실습]

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;

namespace ThisIsCSharpExam
{
    interface ILogger3
    {
        void WriteLog(string message);
        void WriteError(string error);
    }
    class ConsoleLogger3 : ILogger3
    {
        public void WriteLog(string message) {
            Console.WriteLine($"{DateTime.Now.ToLocalTime()}, {message}");
        }
        public void WriteError(string error)
        {
            WriteLog($"Error: {error}");
        }
    }
    class MainApp
    {
        static void Main(string[] args)
        {
            ILogger3 logger3 = new ConsoleLogger3();
            logger3.WriteLog("System Up!");
            logger3.WriteError("System Fail");

            ConsoleLogger3 clogger3 = new ConsoleLogger3();
            clogger3.WriteLog("System Up!");
        }
    }
}

 

 

*혹시 .NET 8.0 이상을 사용하신다면 위와 같이 코드를 작성하셔야 에러가 없이 실행이 가능합니다.

 

 

 

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

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

한마디로, 추상 클래스는 구현을 갖되 인스턴스는 만들지 못합니다. 추상 클래스는 다음과 같이 abstract 한정자와 class 키워드를 이용해서 선언합니다.

 

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

 

인터페이스에서는 모든 메소드가 public으로 선언되는 반면, 클래스는 한정자를 명시하지 않으면 모든 메소드가 private으로 선언됩니다.

 

추상클래스는 인스턴스를 만들 수 없다는 점과 추상 메소드를 가질 수 있다는 점이 있습니다.

 

추상메소드는 추상 클래스가 한편으로 인터페이스의 역할도 할 수 있게 해주는 장치입니다. 구현을 갖지는 못하지만 파생 클래스에서 반드시 구현하도록 강제합니다. 다시 말해, 추상 클래스를 상속하는 클래스들이 반드시 이 메소드를 갖고 있을 거라는 '약속' 입니다.

 

또한, 추상 메소드의 기본 접근성은 public or private 둘 다 입니다.

 

 추상 클래스나 클래스는 그 안에서 선언되는 모든 필드, 메소드, 프로퍼티, 이벤트 모두 접근 한정자를 명시하지 않으면 private 입니다. 여기에는 추상 메소드도 예외가 될 수 없습니다. 하지만 '약속' 역할을 하는 추상 메소드가 private로 둘 수는 없기에 C# 컴파일러는 추상 메소드가 반드시 public, protected, internal, protected internal 한정자 중 하나로 수식 될 것을 강요합니다. => 이렇게 하면 클래스의 접근성 원칙도, 인터페이스의 접근청 원칙도 지켜질 수 있습니다.

 

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

 

[실습]

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ThisIsCSharpExam
{
    abstract class AbstractBase
    {
        protected void PrivateMethodA()
        {
            Console.WriteLine("AbstractBase.PrivateMethodA()");
        }
        public void PublicMethodA()
        {
            Console.WriteLine("AbstractBase.PublicMethodA()");
        }
        public abstract void AbstractMethodA();	// 추상 메소드
    }
    class Derived : AbstractBase
    {
        public override void AbstractMethodA()
        {
            Console.WriteLine("Derived.AbstractMethodA()");
            PrivateMethodA();
        }
    }
    class MainApp
    {
        static void Main(string[] args)
        {
            AbstractBase obj = new Derived();
            obj.AbstractMethodA();
            obj.PublicMethodA();
        }
    }
}

 

 

그렇다면 추상 클래스를 사용하는 이유는?

 

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

 

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

 

하지만 추상 클래스를 이용한다면 이러한 설명이 필요 없습니다. 추상 클래스와 추상 메소드 자체가 이런 설명을 담고 있기 때문 입니다. 혹시 내가 만든 추상 클래스를 이용하는 다른 프로그래머삭 파생 클래스를 만들어야 하며 모든 추상 메소드를 구현해야 한다는 사실을 잊어버린다 해도, 컴파일러가 이를 상기해줄 것입니다. 이것이 우리가 추상 클래스를 사용하는 이유입니다.