[JAVA] 객체지향 프로그래밍 - 인터페이스를 이용한 다형성

2023. 12. 13. 16:49프로그래밍 언어/Java

1. 인터페이스를 이용한 다형성

자손클래스의 인스턴스를 조상타입의 참조변수로 참조하는 것이 가능하다는 것을 이용해서 다형성을 구현한 부분을 공부했다.

인터페이스 역시 해당 인터페이스 타입의 참조변수로 이를 구현한 클래스의 인스턴스를 참조할 수 있으며, 인터페이스 타입으로의 형변환도 가능하다. 

 

인터페이스를 이용한 다형성은 인터페이스 참조변수로 해당 인터페이스를 구현한 클래스 참조하는 것에서 시작한다.

Fightable f = new Fighter();

또, 인터페이스 타입을 메서드의 매개변수 타입으로 사용하거나 리턴 타입으로 사용할 수 있다.


인터페이스 타입의 매개변수가 갖는 의미는 메서드 호출 시 해당 인터페이스를 구현한 클래스의 인스턴스를 넘겨주어야 한다는 의미이다.

class Fighter extends Unit implements Fightable {

    public void attack(Fightable f){ }

}

위 예제에서 attack 메서드를 호출할 때는 매개변수로 Fightable 인터페이스를 구현한 클래스의 인스턴스를 넘겨줘야 한다.

그리고 리턴타입이 인터페이스라는 것은 메서드가 해당 인터페이스를 구현한 클래스의 인스턴스를 반환한다는 것을 의미한다. 이런 특성들을 종합한 예시를 보자.

interface Parseable{
    public abstract void parse(String fileName);
}

class ParseManager{
    public static Parseable getParser(String type){
        if(type.equals("XML")){
            return new XMLParser();
        }else{
            Parseable p = new HTMLParser();
            return p;
        }
    }
}

class XMLParser implements Parseable{
    public void parse(String fileName){
        System.out.println(fileName + "- XML parsing completed.");
    }
}

class HTMLParser implements Parseable{
    public void parse(String fileName){
        System.out.println(fileName + "- HTML parsing completed.");
    }
}

class Main{
    public static void main(String[] args) {
        Parseable parser = ParseManager.getParser("XML");
        parser.parse("temp1.xml");
    }
}

이렇게 인터페이스로 다형성을 구현한다면, 만약 나중에 새로운 종류의 XML구문분석기 NewXMLParser클래스가 나와도 Main 클래스의 변경 없이 ParserManager클래스의 getParser메서드만 변경하면 된다. 


2. 인터페이스의 장점

  1. 개발시간을 단축시킬 수 있다.
    • 일단 인터페이스가 작성되면, 메서드를 호출하는 쪽에서는 메서드의 내용에 관계없이 선언부만 알면 되므로 인터페이스로 프로그램을 작성할 수 있다.
  2. 표준화가 가능하다.
  3. 서로 관계없는 클래스들에게 관계를 맺어줄 수 있다.
  4. 독립적인 프로그래밍이 가능하다.
    • 클래스의 선언과 구현을 분리시킬 수 있다. 따라서 한 클래스의 변경이 관련된 다른 클래스에 영향을 미치지 않는 독립적인 프로그래밍이 가능하다.

이런 장점들을 잘 보여주는 예시를 보자. 게임에 나오는 유닛을 클래스로 표현하고 이들의 관계를 표현해 보았다.

이 예시에서 SCV, Tank, Dropship 유닛은 수리가 가능하다. 따라서 각 클래스에 repair 메서드를 정의한다면 수리가 가능한 유닛의 개수만큼 다른 버전의 오버로딩된 메서드를 정의해야 할 것이다.

이를 피하기 위해 매개변수 타입을 이들의 공통 조상으로 하고 싶지만, 공통조상 Unit이나 GrountUnit에는 수리할 수 없는 유닛이 포함될 수 있기에 매개변수로 GroundUnit 등을 사용하는 것은 부적절하다.

 

현재의 상속관계에서는 수리가능한 유닛들의 공통점은 없다. 이때 인터페이스를 이용하면 기존의 상속체계를 유지하면서 수리가능한 유닛에 공통점을 부여할 수 있다. Repairable이라는 인터페이스를 정의하고, 수리 가능한 유닛에 Repariable을 구현하도록 하면 된다. 그리고 SCV 클래스에 repair 메서드를 추가하면 되는데, repair 메서드만 보자.

class SCV extends GroundUnit implements Repairable{
    /*생성자 등 생략*/
    void repair(Repairable r){
        if(r instanceof Unit){
            Unit u = (Unit)r;
            // u 수리
        }
    }
}

repair메서드의 매개변수 r은 Repairable타입이기 때문에 인터페이스 Repairable에 정의된 멤버만 사용할 수 있다. 따라서 캐스팅한 뒤 수리를 시키면 된다.

 

그런데 이 예시에는 SCV가 모든 유닛을 수리하기에 이처럼 사용했다. 그럼 특정 클래스에 repair 메서드를 추가하는 것이 아닌 예제는 모든 클래스에 인터페이스의 메서드를 오버라이딩해서 사용해야 할까? 아니다.

interface Repairable{
    void repair();
}

class RepairableImpl implements Repairable{
    public void repair(){
        //유닛 수리
    }
}

class SCV extends GroundUnit implements Repairable{
    RepairableImpl r = new RepairableImpl();
    void repair(){
        r.repair();
    }
}

이런 식으로 코드를 재사용하면서 구현할 수 있다.


3. 디폴트 메서드와 static 메서드

원래는 인터페이스에 추상 메서드만 선언할 수 있었는데, JDK1.8부터 디폴트 메서드와 static 메서드를 추가할 수  있게 되었다. 

 

조상 클래스에 새로운 메서드를 추가하는 것은 별 일 아니지만, 인터페이스의 경우에는 상당히 번거로운 일들이 발생한다. 그래서 고안된 것이 디폴트 메서드이다. 디폴트 메서드는 추상 메서드의 기본적인 구현을 제공하는 메서드로, 추상 메서드가 아니기에 디폴트 메서드가 새로 추가되어도 해당 인터페이스를 구현한 클래스를 변경하지 않아도 된다.

 

default 키워드를 붙여 선언하면 되고 조상 클래스에 새로운 메서드를 추가한 것과 동일한 일이 발생한다. 대신, 새로 추가된 디폴트 메서드가 기존 메서드와 중복되는 경우가 발생하는데, 이 충돌을 해결하는 규칙은 다음과 같다.

  1. 여러 인터페이스의 디폴트 메서드 간의 충돌
    인터페이스를 구현한 클래스에서 디폴트 메서드를 오버라이딩해야 한다. 헷갈린다면 그냥 필요한 쪽의 메서드를 같은 내용으로 오버라이딩하면 된다.
  2. 디폴트 메서드와 조상 클래스의 메서드 간의 충돌
    디폴트 메서드는 무시된다.