앞서, 추상 클래스와 추상 함수에 대해 알아봤었다.
이번에 다룰 인터페이스는 추상 클래스와 굉장히 유사한 개념이지만, 다른 부분이 분명히 존재하는 개념이다.
그렇기에 어떤 부분이 다른지, 어떻게 사용하는지에 대해 확실히 알아보고자 한다.
추상 클래스는 실체는 없어도 어엿한 부모였으며, 추상 함수 역시 실체는 없지만 부모가 자식에게 이렇게 하도록 해라 라고 하는 지침이었다.
이러한 특징이 있기에 추상 클래스도 추상 함수도 상속받은 자식 클래스와의 관계가 뚜렸했으며, 서로간의 유대가 깊었다.
이번에 배울 인터페이스는 추상 클래스와는 사뭇 다른 느낌이다.
인터페이스도 마찬가지로 실체가 없는 점에서 추상 클래스와 같다.
인터페이스 역시 추상 클래스 처럼 함수를 선언하고, 정의는 할 수 없으며, 인터페이스를 상속받은 클래스에서 해당 함수의 내용을 구체화해야지만 사용할 수 있다.
추상 클래스와 동일한 특징중 하나로, 인스턴스 될 수 없다는 것이 있다. 즉, 객체로서 존재할 수 없다는 것이다.
또, 인터페이스에서 선언한 함수가 상속받은 자식클래스에서 구체화되지 않는다면 자식클래스에서는 런타임 에러가 생긴다.
즉, 추상 클래스와 마찬가지로 인터페이스에서 선언된 함수는 자식 클래스에서 반드시 구체화되어야한다.
여기까지만 본다면 거의 같다고도 할 수 있을 것 같다.
공통점을 정리하면,
첫째. 함수를 선언하고 정의하지 않는다.
둘째. 상속받을 수 있다.
셋째. 상속받은 자식 클래스는 반드시 선언했던 함수를 구체화해야한다.
넷째. 인스턴스가 불가능하다.
추상 클래스에서 배웠던 추상 클래스의 특징에 대부분을 지니고 있다. 그럼 다른 점을 통해 알아보자.
먼저, 인터페이스는 추상클래스보다도 더 실체가 없다. 왜냐하면, 인터페이스에는 변수를 넣을 수 없기 때문이다.
따라서 인터페이스에게 허용된 것은, 우리가 아는 범위 안에서만 설명하면 프로퍼티와 함수 뿐이다.
그래서 인터페이스에는 기능에 관련한 요소만 담을 수 있다.
차이점이 하나뿐이지만 꽤 강렬하다. 변수를 지닐 수 없이, 함수를 통해 기능만 보유해야한다.
심지어, 추상 클래스처럼 함수의 정의조차 할 수 없다.
지금까지 배웠던 것 중에 가장 '틀'의 개념이 강한 것 같다.
이정도면 틀이 아닌 껍데기에 가까울지도 모른다.
이 빈 껍데기를 어떻게 사용해야 할지 차근차근 알아보자.
위까지는 인터페이스의 요소에 대해서 알아보았고, 이제는 인터페이스의 개념을 알아보자.
인터페이스의 개념을 간략하게 설명하자면, 인터페이스는 앞서 말했던 것 처럼 '틀' 이다.
인터페이스는 앞서 설명했 듯, 실체가 없으며, 가질 수 있는 요소도 굉장히 제한적이다.
따라서 객체 구성에 있어 큰 부분을 차지하기는 힘들다.
그러나, 객체 구성의 비중이 적다고 중요도까지 낮다는 것은 아니다.
인터페이스 역시, 객체지향에 있어서 필수불가결한, 객체지향의 꽃이라고 불릴 수 있기 때문이다.
우선, 인터페이스를 '틀' 이라고 했던 이유를 알아보기 전에, 인터페이스를 만드는 법을 알아보자.
using System;
namespace Favor
{
public interface IOpenable
{
void Open();
}
class Door : IOpenable
{
public void Open() { Console.WriteLine("문을 엽니다."); }
}
}
추상 클래스 때와 굉장히 유사하다. abstract 대신 interface라는 키워드를 사용하였다.
그러나 내부의 함수에도 interface라는 키워드는 사용하지 않았다.
일반 함수를 선언할 때 처럼 선언하면 된다. 그러나, 접근제한자는 사용할 수 없다.
즉, 인터페이스의 함수 선언에는 반환값(void, int ...), 함수이름, 매개변수(매개변수는 없을 수도 있다.) 총 세개가 필요하다.
자, 이제 본격적으로 인터페이스를 틀 이라고 부른 이유를 알아보자.
보다시피 인터페이스의 내부에는 넣을 내용이 없다.
함수를 선언할 수는 있으나, 정의할 수 없기 때문에 함수의 내용을 구체적으로 적을 수 없다.
상속 받은 자식 클래스가 이 함수의 내용을 구체화시켜 사용할 수 있다.
추상 클래스를 다룰 때, 마지막으로 설명했던 것 처럼, 인터페이스 역시 상속받은 자식클래스의 내용을 강제할 수 있는데,
이것이 인터페이스의 중요한 역할이다.
인터페이스는 상속받을 자식클래스가 반드시 특정 기능을 가질 것을 강제하는 기능을 담당한다.
즉, 인터페이스를 통해 '어떠어떠한 기능을 가질 것.' 이라는 규칙을 정해, 그 틀에 맞게 자식클래스를 찍어내는 것이다.
이렇게 틀을 만든 후, 자식클래스에게 상속시켜줌으로써, 하나의 약속을 만든다.
인터페이스를 약속 으로 비유한 다른 비유들이 많은데, 내가 이해하기로는,
인터페이스 자체는 틀이고, 인터페이스를 자식 클래스에게 상속시켜주는 행위가 약속인 것이다.
해당 내용의 이해를 돕기 위해, 아래의 코드를 참고하겠다.
using System;
namespace Favor
{
public interface IOpenable // 이 인터페이스를 상속받은 클래스는 문을 열 수 있어야 합니다.
{
void Open(); // 문을 여는 기능
}
class Store
{
IOpenable _door; // a2. 문을 여는 기능을 사용할 수 있는 멤버를 선언
public Store(IOpenable door) { _door = door; } // a3. 문 여는 방법은 외부에서 정하기로함
public void OpenStore()
{
Console.WriteLine("가게 문을 열기 위해 ");
door.Open(); // a1. 문을 여는 기능을 사용하고싶음.
}
}
class PullDoor : IOpenable // 문을 여는 기능을 여닫이로 여는 기능으로 구체화
{
public override void Open() { Console.WriteLine("문을 여닫이로 연다."); }
}
class PushDoor : IOpenable // 문을 여는 기능을 미닫이로 여는 기능으로 구체화
{
public override void Open() { Console.WriteLine("문을 미닫이로 연다."); }
}
internal class Program
{
static void Main(string[] args)
{
Store pullStore = new Store(new PullDoor()); // a4. 여닫이로 열기 위해 여닫이 기능을 매개로 전달
Store pushStore = new Store(new PushDoor()); // a4. 미닫이로 열기 위해 미닫이 기능을 매개로 전달
pullStore.OpenStore();
pushStore.OpenStore();
// 출력값
// 가게 문을 열기 위해 문을 여닫이로 연다.
// 가게 문을 열기 위해 문을 미닫이로 연다.
}
}
}
코드의 내용이 길지만, 설명하면서 차근차근 같이 읽어보도록 하자.
우선 인터페이스를 IOpenable로 설정했다.
이 인터페이스를 상속받을 자식클래스에게 모두 '문을 여는 기능을 가질 것' 을 약속했다.
이 인터페이스를 두개의 클래스가 상속받았다.
각각, 문을 열긴 여는데, 한 쪽은 미닫이로, 다른 한 쪽은 여닫이로 여는 기능을 가지게 되었다.
이렇게 문을 여는 기능을 갖고 실체를 가질 수 있는 객체가 둘 완성이 되었다.
또, Store라는 클래스가 있다.
Store클래스는 문을 연다는 자신의 메서드, OpenStore를 실행시키기 위해 문을 여는 기능이 필요하다.
문을 여는 기능을 사용하기 위해, 문을 여는 기능을 갖고있는 IOpenable인터페이스를 멤버 door로 선언했고,
이 door로 IOpenable의 기능을 사용할 것이다.
자, 여기까지가 각 객체들의 설명이다. 이제 본론으로 들어가보자.
우선, Store는 문을 열고싶다.(a1에 해당함)
그러나, 여닫이 문으로만 열고싶지 않았다.
혹시나 여닫이 문이 고장날 것을 대비해, 미닫이 문이나 자동문 등, 여러 방법으로 가게문을 열 필요가 생각해서,
한 가지 방법을 고집하지 않고 다양한 방법으로 문을 열기 위해, '여닫이로 문을 연다.' 라고 결정짓지 않고,
'일단은 문을 열긴 연다.'와 같이 추상적으로 그 개념을 정해뒀다. (여기까지 a2.에 해당한다.)
(만약 Store가 '미닫이로 문을 연다.' 라고 결정했다면, 멤버로 IOpenable이 아닌, PushDoor로 설정했을 것이다.)
이렇게 추상적으로 문을 열기로 정한 후에, 이 문을 여는 방식은 가게를 만들때 정하고자 하였다.
(a3.에서 생성자의 매개변수를 통해 외부에서 문을 여는 방식을 정하기로 한 것이 이에 해당함.)
그래서 가게를 만들었다.
(메인 함수에서, Store객체를 인스턴스 한 것을 가게를 연 것으로 비유)
가게를 열었으니, 이제 가게 문을 미닫이로 할지 여닫이로 할지 결정할 차례다.
문을 미닫이로 열고싶은 pushStore는 문을 미닫이로 열기 위해 매개로 미닫이 문으로 열겠다고 할 것이다.
그런데 이상하다. 분명 매개변수를 인터페이스인 IOpenable로 설정했는데, 매개에는 new PushDoor()가 있다.
다시말해, 인터페이스를 매개로 받아야 할 곳에 새 객체인 미닫이 문이 들어간 것이다.
인터페이스는 추상 클래스와 마찬가지로 객체가 될 수 없다.
따라서, 메인 함수에 들어갔을 때, 인터페이스를 매개로 받고싶어도 받을 수가 없는 것이다.
만약 위의 가게 문의 예로 설명하자면, '문을 열기는 연다' 와 같은 문열기 방법은 이 세상에 존재하지 않는 것이다.
그렇기 때문에, 꼭, 문을 여는 내용을 구체화해서 매개로 전달해야한다.
그런데 앞서 말했듯, 인터페이스를 매개로 받기로 했으면 인터페이스를 넣어줘야 하는데, 인터페이스를 상속만 받은 자식클래스 PushDoor가 와도 되는걸까? 아빠를 불렀는데 아들을 데리고 간 꼴이다.
프로그래밍의 세계에선 가능하다. 왜냐하면, 업 캐스팅 이라는 개념 때문이다.
업 캐스팅은 자식 객체가 부모 객체로 형변환 하는 것을 말한다.
여기서는 PushDoor가 IOpenable에게 상속받았기 때문에, 부모는 IOpenable이고 자식은 PushDoor다.
업 캐스팅은 컴파일러가 자동으로 변환해준다.
따라서 여기서 Store생성자의 매개변수로 IOpenable이 아닌, IOpenable을 상속받은 자식 클래스, PushDoor를 인스턴스 했을 때도 마찬가지로 PushDoor는 부모인 IOpenable로 형변환 한 것이다.
(a4. new Store(new PushDoor()); 에서, Store의 매개변수를 넣어야 할 소괄호안에 new PushDoor()로 PushDoor를 인스턴스 한 것이 이에 해당함)
이렇게 해서 문을 열 때, 미닫이로 문을 열기로 정한 것이다.
(a4.가 이에 해당함.)
위와 같은 과정을 모두 거치고, 미닫이로 가게 문을 열 pushStore와, 여닫이로 문을 열 pullStore가 정상적으로 가게 문을 여는 것을 볼 수 있다.
예시를 맺고 다시 본론으로 돌아와서, 1.인터페이스 와 2.인터페이스를 상속받을 자식클래스, 3.인터페이스 기능을 이용하고자 하는 객체를 각각, 1.인터페이스 2.자식 3.이용자 라고 부를 때,
인터페이스는 앞서 말했듯 틀이자, 상속받을 객체들이 지켜야할 룰이다.
인터페이스 안에 함수를 선언함으로써, 이제 앞으로 이 인터페이스를 상속받을 자식들은 모두 이 함수를 써야합니다. 하고 약속하는 것이다.
인터페이스만 있으면, 같은 기능을 가졌으나, 세부적인 내용을 설정하여 각각 다른 기능을 만들어낼 수 있다.
즉, 위에서 미닫이 기능, 여닫이 기능을 만든 것 처럼, 인터페이스 라는 틀을 이용해 객체를 만들면, 다형성을 확보할 수 있다는 뜻이다.
인터페이스는 틀 안에서 서로가 공통점은 공유한채, 각자의 다형성을 확보한 채로 자식들을 찍어낼 수 있는 역할을 하는 것이다.
이렇게 다형성을 갖춘 자식들이 많이 생겼기 때문에, 이용자는 여러 상황에 대비하여 다양한 인터페이스의 기능을 다형성을 갖춘 자식들을 통해서 상황에 맞게, 기능에 맞게 이용할 수 있게 된다.
위의 예시에서, Store가 문을 열고는 싶은데, 다양한 방법으로 열기를 원했던 것 처럼, 이에 맞게 문을 연다는 틀을 제공하고, 그 틀 안에서 여닫이, 미닫이, 자동문같은 자식들을 만들어내, 이용자가 여러 상황에 대처할 수 있게 만들어주는 것이다.
이렇듯 인터페이스는 객체지향의 4대 특징인 상속, 추상화, 캡슐화, 다형성을 모두 지니고있다.
인터페이스의 함수 내용을 추상적으로 설정하여, 상속받을 객체를 통해 구체화했기 때문에 추상화를 만족했고,
인터페이스라는 틀을 기반으로, 인터페이스의 기능을 재정의하여 여러 상황에 대처할 수 있는 자식들을 만들었기 때문에 다형성을 확보했으며,
인터페이스를 통해 객체들을 각각 기능별로 묶어 만들었기 때문에 캡슐화 역시 확보하였다.
또한, 하나의 인터페이스를 통해 다양한 객체들이 인터페이스가 갖고있는 기능을 지니고 있기 때문에 상속이라 할 수있다.
물론 실제로도 인터페이스가 자식객체들을 상속하고 있다.
인터페이스는 실체가 없기 때문에, 추상 클래스처럼 상속하지 않고서는 의미가 없기 때문이다.
인스턴스에 대해 알아보고 나니, 상속을 해주는 추상 클래스와는 구조적으로는 상당히 유사하나, 개념적으로 많이 다르단 것을 알게되었다.
추상 클래스는 그래도 어엿한 부모고, 자식 클래스들이 가져야 할 상태나 정보, 기능들을 모두 가지고 있으며, 자식에게 추상클래스가 반드시 필요하며, 추상클래스에게도 자식이 반드시 필요한 끈끈한 연대를 가지고있었다.
반면, 인터페이스는 표면적으로는 상속의 형태를 띄고있지만, 물려받는 것도 기능 뿐이고, 인터페이스를 상속받는 자식의 입장에서 부모라고 느끼기엔 조금 무리가 있어보인다.
클래스의 상속과 인터페이스의 상속간의 개념 차이를 간략하게 짚고 가보자
클래스의 상속은 철저히 관계주의이며, 포함관계에 있다.
사과를 예를들어보면, 사과는 과일에 속한다. 라고 정의할 수 있지 않은가? 여기서 사과가 자식, 과일이 부모라고 볼 수 있다.
반면 인터페이스는 상속받는 자식과 인터페이스가 포함관계에 있지는 않은 것 같다.
상속받는 객체는 그저 인터페이스라는 하나의 범주로 묶였으며, 해당 기능을 탑재했다는 꼬리표 정도인 것 같다.
인터페이스는 가능의 의미를 갖는다. 앞서 클래스의 상속이 A는 B에 속해있다. (A is in B)의 의미를 가졌다면,
인터페이스는 A는 B할 수 있다. (A is able to B)의 개념이 강하다.
앞선 예에서, PushDoor는 IOpenable을 상속받아 문을 열 수 있게 되지 않았는가.
인터페이스가 자식 객체에게 해당 기능을 강요함으로써, 자식 객체는 해당 기능을 구현할 수 있게 된 것이다.
클래스간의 상속이야 말로 부모 자식간의 예를 들 수 있고, 인터페이스는 그저 기능의 탑재이다.
여러분들은 최근 애니메이션화 되기도 했던 사이버펑크라는 게임을 아는가?
약 50년 후의 도시를 배경으로 다룬 사이버펑크에는 신체에 이식하여 신체 능력을 강화 혹은 확장해주는 기계부품인 임플란트가 있다.
예를들어, 기계 눈을 이식하여 해킹을 돕는다거나, 중추신경에 이식하여 인간의 반응속도를 극도로 상승시켜주듯 말이다.
인터페이스는 이 임플란트와 닮아있다.
어떤 객체에 인터페이스를 이식하면, 어쨌거나 특정 기능을 수행할 수 있게되기 때문이다.
인터페이스를 배움으로써 이제 모든 상속관계의 파악이 완료됐다.
이와같이 다양한 방법으로 클래스의 상속이 이루어지는 것을 알았으니, 이제 어떤 상황에서 어떻게 상속시켜야할지를 파악하는 것이 중요하다.
먼저, 부모가 갖는 기능을 온전히 자식이 물려받을 수 있는 상황이다.
어떠한 상황이 되어도, 자식은 부모의 기능을 가지고 모든 상황에 대처가 가능하다면, 일반 메서드를 상속시켜준다.
다음으로, 부모가 갖는 기능을 자식이 물려받되, 온전하게 받진 못하고 상황에 따라 변형 내지 확장이 필요한 경우다.
부모의 기능을 사용하다보면, 특정한 상황에서, 부모와는 다른 기능을 사용해야 하는 객체들이 있기 마련이다.
이럴 때는 가상함수로 선언하여 자식마다 다른 기능을 수행할 수 있도록 오버라이드 해주면 된다.
셋째로는, 자식들의 특징들이 서로 뚜렷하여, 부모에서는 지킬 것만 지키게 하고, 그 규칙 안에서 각자의 기능을 구체화할 때이다.
그러니까, 10. 추상 클래스 에서 다루었던 Item클래스가 이에 해당한다.
일단은 아이템이니 사용하기는 하는데, 아이템마다 사용효과가 전부 다르기 때문에, 부모 클래스에서 추상화만 시킨 후, 자식에서 구체화하는 방향이다.
물론 추상 클래스에서 아이템을 사용하면 갯수가 줄어든다. 아이템을 사용하면 재사용시까지 시간이걸린다 등, 규칙이나 틀등은 정해주어야 한다.
이럴 때는 추상 클래스와 추상 함수를 사용하면 된다.
마지막으로, 상속하려는 두 객체 사이에 연관성이 크게 없고, 기능을 추가하거나, 해당 기능을 클래스가 갖게하는 것이 목적이라면, 인터페이스를 상속시켜준다.
클래스의 상속에서는, 부모가 가진 특징중 대부분이 없다면 자식이 될 수 없다.
철저히 상위와 하위의 개념을 서로 갖고있는 것이 클래스의 상속인데, 간혹 그렇지 않은 관계도 있다.
예를들면, 문을 열수 있다 라는 개념과, 탈 것 이라는 개념이다.
문을 열 수 있는 것이 모두 탈 것인 것도 아니고, 문을 열 수 없다면 탈 것이 아닌게 되는 것도 아니다.
이러한 개념을 부모 자식으로 엮으면, 집도 탈 것이고, 자전거는 탈 것이 아닌 것이다.
따라서, 특징이 아닌 기능만을 클래스에게 부여하고 싶을 때. 기능의 추상적인 제약조건만 걸어둔 채, 해당 기능을 구체적으로 수행할 수 있는 여러 객체를 만들어주고 싶다면, 인터페이스를 사용해야하는 것이다.
인터페이스가 객체지향의 꽃이라고 불리우는 이유를 처절히 깨달았다.
공부하면서 정말 이해가 안되고, 가장 이해하는데 시간이 오래걸린 개념이지만, 인터페이스를 빼고서 객체지향의 4대 특징이나, SOLID원칙을 다루는 것 자체가 불가능했을 정도다.
기존에 알고있던 인터페이스의 개념과, 공부하면서 새롭게 배우게 된 인스턴스의 개념이 많이 달라서, 다시 공부하길 잘했다라는 생각도 들었다.
'C# 일기' 카테고리의 다른 글
| 13. String과 StringBuilder (2) | 2024.03.08 |
|---|---|
| 12. 객체지향의 특징 (0) | 2024.03.06 |
| 10. 추상 클래스(Abstract Class) (2) | 2024.03.06 |
| 9. 가상 함수와 오버라이드 (Virtual & Override) (0) | 2024.03.06 |
| 8. 클래스 상속 (0) | 2024.03.05 |