class Drinks {
private Coffee coffee;
private Coke coke;
public void drink(Coffee coffee) {
shakeByStraw();
drinkByStraw();
}
public void drink(Coke coke) {
pickCan();
drinkDirectly();
}
}
위의 예시를 간략하게 구현한 코드입니다. Drinks 클래스 내부에 정의된 drink라는 함수는 인자로 어떤 종류의 음료를 받느냐에 따라 실행되는 내부 로직이 달리 구현되어 있습니다. 이를 메소드 오버로딩이라 하고 다형성의 대표적인 구현 방법이다.
추가로, 부모 클래스로부터 상속을 받은 메서드의 기능을 수정하고 싶을 수 있습니다. 이 때, 부모 클래스를 직접 수정하게 되면 그 메서드를 상속받고 있는 나머지 클래스에도 영향을 미치게 됩니다. 이런 경우 부모가 상속해준 메서드를 자식 클래스에서 수정하여 사용할 수 있습니다. 이것을 메소드 오버라이딩이라고 합니다.
public abstract class Shape {
public abstract double getProperty();
}
public class Polygon extends Shape {
@Override
public double getProperty() {
return Calculator.calculatePolygonArea(pointCollection);
}
}
메소드 오버라이딩을 적용한 예제 코드입니다. Polygon이라는 객체는 Shape를 상속받고, Shape에서 정의한 getProperty 함수를 상속받습니다. (이 경우 추상클래스의 추상메서드이기 때문에 반드시 상속받아서 오버라이드해야 하는 경우입니다.) 만약 추상클래스가 아니였더라도 Polygon에서 해당 메소드를 오버라이딩하여 동작하기 바라는 기능을 구현할 수 있습니다.
즉, 부모와 자식이 같은 메소드를 사용하지만 동작하는 방식이 다르므로 다형성을 구현할 수 있는 방법입니다.
class ThisExample {
private int age;
public ThisExample(int age) {
this.age = age;
}
}
위 클래스의 생성자는 age라는 변수를 받아 인스턴스 멤버인 age를 초기화해주는 역할을 합니다. 그런데, 매개 변수의 이름과 인스턴스 변수 이름이 age로 같은 상황입니다. 그래서 age라는 멤버 변수를 가리키기 위해 this라는 키워드를 사용합니다. 또한, this()라는 것은 현재 클래스의 생성자를 의미합니다. 또한, 인스턴스를 가리키고 있기 때문에 클래스 변수에 대해서는 접근이 불가능합니다. 모든 인스턴스 메서드에는 자신과 연관된 인스턴스를 가리키는 참조변수 this가 지역변수로 숨겨진 채로 존재합니다.
// 부모 클래스
protected void setAlarm() {
Alram.create("07:00");
}
부모 클래스에서 7시에 알람을 맞추는 함수를 만들었습니다.
// 자식 클래스
@override
protected void setAlarm() {
Alram.create("08:00");
}
자식 클래스는 이 함수를 오버라이드하여 8시에 알람을 맞추기로 하였습니다. 하지만 부모 클래스의 함수가 중요하여 항상 7시에는 알람을 맞춰야 한다면 어떻게 해야 할까요? 오버라이드를 할 때, 부모 클래스의 함수 또한 호출하면 됩니다.
// 자식 클래스
@override
protected void setAlarm() {
super.setAlram();
Alram.create("08:00");
}
super 키워드를 이용하면 상속하고 있는 부모 클래스에 접근할 수 있고, 부모 클래스의 setAlarm을 호출함으로써 7시와 8시에 알람을 설정할 수 있게 됩니다.
this와 마찬가지로 super()는 부모클래스의 상속자를 뜻합니다. 또한 static 메서드나 멤버에 대해서는 접근할 수 없습니다.
이를 적용하기 위해 어떤 클래스를 쪼갬으로써 각 책임을 개별 클래스로 분할하여 클래스 당 하나의 책임만을 맡도록 합니다. 책임만 분리하는 것이 아니라 분리된 두 클래스간의 관계의 복잡도를 줄이도록 설계합니다. 만약 쪼갠 클래스 각각이 유사하고 비슷한 책임을 중복해서 갖고 있다면 공통된 Superclass를 만드는 것도 방법입니다. 각 클래스에서 공유되는 요소를 부모 클래스로 정의하여 부모 클래스에 위임하는 기법입니다.
이를 적용하기 위해 어떤 요소를 만들 때 다음과 같은 원칙으로 생각해봅니다.
1. 변경될 것과 변하지 않을 것을 엄격히 구분합니다.
(예를 들어 고유 정보 등의 변하지 않는 식별 정보, 수학 공식 등에 필요한 상수 vs 변동되는 가격정보, 공식에 사용되는 변수)
2. 이 두 모듈이 만나는 지점에 인터페이스를 정의합니다.
3. 구현에 의존하기보다 정의한 인터페이스에 의존하도록 코드를 작성합니다.
요약하자면, 서브 타입은 언제나 기반 타입으로 교체할 수 있어야 한다. 입니다. 상속한 클래스가 슈퍼 클래스의 타입으로 언제든지 대체될 수 있다는 것입니다. 즉, 슈퍼 클래스에서 제공하는 인터페이스를 어겨서는 안됩니다. 상속은 궁극적으로 다형성을 통한 확장성 획득을 목표로 합니다. 다형성과 확장성을 극대화하려면 하위 클래스를 사용하는 것보다는 상위의 클래스(인터페이스)를 사용하는 것이 더 좋습니다. 일반적으로 ArrayList를 선언할 때 아래와 같이 선언은 상위 클래스로, 생성은 구체 클래스로 하는 것을 알 수 있습니다.
List<> newList = new ArrayList<>();
즉, 이 객체는 내부적으로 ArrayList 생성자를 통해 생성되지만 타입은 업캐스팅하여 List<>입니다. 처음에 ArrayList로 생각하고 설계를 진행하다가 자료의 추가/삭제가 빈번해 LinkedList로 변경하고 싶다면 어떻게 해야 할까요? 만약 ArrayList로 선언했다면 리턴 타입이나 매개 변수 등을 전부 수정해야 합니다. 그러나 만약 List로 업캐스팅하여 코드를 작성했다면 단지 선언부만 LinkedList로 바꾸면 됩니다. 이처럼 객체는 인터페이스를 통하여 선언하는 것이 변경에 더 유연하다는 것을 알 수 있었습니다.
이를 적용하기 위해 다음과 같은 원칙을 생각해봅니다.
1. 만약 두 개체가 똑같은 일을 한다면 둘을 하나의 클래스로 표현하고 이들을 구분할 수 있는 필드를 둡니다.
2. 똑같은 연산을 제공하지만, 이들을 약간씩 다르게 한다면 공통의 인터페이스를 만들고 둘이 이를 인터페이스 상속하여 구현합니다.
3. 공통된 연산이 없다면 완전 별개인 2개의 클래스를 만듭니다.
4. 만약 두 개체가 하는 일에 추가적으로 무언가를 더 한다면 구현상속을 사용합니다.
이 원리는 자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다는 원리입니다. 즉 어떤 클래스가 다른 클래스에 종속될 때에는 가능한 최소한의 인터페이스만을 사용해야 합니다. 즉, 일반적인 하나의 인터페이스보다는 구체적인 여러개의 인터페이스가 낫다라고 정의할 수 있습니다. 특정 클래스를 이용하는 클라이언트(서브 클래스)가 여러개고, 그 클래스의 특정 부분만 이용한다면, 그 부분을 분리하여 인터페이스로 만들어 사용하는 것을 권장합니다. 일종의 인터페이스의 단일 책임 원칙이라 볼 수 있습니다.
이 원칙은 한마디로 특정하게 구현된 저수준 모듈이 아닌 추상화된 고수준 모듈에 의존하라는 원칙입니다. 개방-폐쇄 원칙과도 밀접한 관계가 있습니다. 예를 들어 어떤 게임 캐릭터 객체에 특정 무기를 담아서 생성한다고 생각해봅시다. 만약 그 캐릭터가 다른 무기를 착용하면 어떻게 해야할까요? 생성자 자체를 수정해야 합니다. 우선 개방-폐쇄 원칙을 위배하고 있습니다. 게다가 특정 무기라는 저수준 모듈을 의존하고 있는 상황입니다. 이를 탈피하기 위해서는 무기 클래스를 아우르는 추상화된 인터페이스가 필요합니다. 그 다음 캐릭터는 그 인터페이스를 생성자로 받아 구체적인 무기 타입을 입력받으면 됩니다.