개발을 할 때 가장 신경써야 하는 것 중 하나가 중복을 제거하여 변경을 쉽게 만드는 것이다. 객체지향 프로그래밍의 장점 중 하나는 코드를 재사용하여 중복을 제거하기에 용이하다는 것인데, 이를 위한 방법에는 크게 상속과 합성 두 가지가 있다.
상속은 상위 클래스에 중복 로직을 구현해두고 이를 물려받아 코드를 재사용하는 방법이다. 흔히 상속은 Is-a 관계라고 많이 불린다.
class Animal {
void eat() {
System.out.println("Eating...");
}
}
class Dog extends Animal {
void bark() {
System.out.println("Barking...");
}
}
public class InheritanceExample {
public static void main(String[] args) {
Dog myDog = new Dog();
myDog.eat(); // 상속받은 메서드 호출
myDog.bark(); // 자식 클래스의 메서드 호출
}
}
class Engine {
void start() {
System.out.println("Engine starting...");
}
}
class Car {
Engine engine;
Car(Engine engine) {
this.engine = engine;
}
void drive() {
engine.start();
System.out.println("Car is moving...");
}
}
public class CompositionExample {
public static void main(String[] args) {
Engine myEngine = new Engine();
Car myCar = new Car(myEngine);
myCar.drive(); // 합성을 통한 기능 조합
}
}
- 결합도는 하나의 모듈이 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 정도이다.
- 객체지향 프로그래밍에서는 결합도는 낮을수록, 응집도는 높을수록 좋다.
- 객체지향의 장점 중 하나는 추상화에 의존함으로써 다른 객체에 대한 결합도는 최소화하고 응집도를 최대화하여 변경 가능성을 최소화하는 것이다.
대표적인 Vector, Stack 예제
Stack
클래스는 Vector
클래스를 상속받는다. 그래서 Stack
클래스가 제공하는 push
, pop
이지만 Vector
클래스의 add
메소드 또한 외부로 노출되게 된다. 그러면서 아래와 같이 의도치 않은 동작이 실행되면서 오류를 범하게 된다.
Stack<String> stack = new Stack<>();
stack.push("1st");
stack.push("2nd");
stack.push("3rd");
stack.add(0, "4th");
assertEquals("4th", stack.pop()); // 실패!!!!
여기서 문제는 Stack
클래스의 add(int index, E element)
메서드를 사용하여 요소를 삽입하면 LIFO (Last-In-First-Out) 구조를 깨뜨리게 됩니다. 스택에서는 새로운 요소가 항상 맨 위에 추가되어야 하는데, add
메서드를 사용하면 지정된 인덱스에 요소가 삽입되어 예상과 다르게 동작합니다.
상속으로 인해 결합도가 높아지면 다음과 같은 두 가지 문제점이 발생한다.
간단한 음식점 예제
class Food {
protected int price;
public Food(int price) {
this.price = price;
}
public int getPrice() {
return price;
}
}
class Bread extends Food {
private String type;
public Bread(int price, String type) {
super(price);
this.type = type;
}
public String getType() {
return type;
}
}
public class FoodExample {
public static void main(String[] args) {
// Creating a Food object
Food food = new Food(10);
// Creating a Bread object
Bread bread = new Bread(5, "Whole Wheat");
displayFoodInfo(bread);
}
}
discount 추가
class Food {
protected int price;
protected int discount; // 코드 추가
public Food(int price, int discount) {
this.price = price;
this.discount = discount;
}
public int getPrice() {
return price - discount; // Apply discount
}
public int getDiscount() {
return discount;
}
}
class Bread extends Food {
private String type;
public Bread(int price, int discount, String type) {
super(price, discount); // 코드 수정
this.type = type;
}
public String getType() {
return type;
}
}
public class FoodExample {
public static void main(String[] args) {
// Creating a Food object
Food food = new Food(10, 2); // 코드 추가
// Creating a Bread object
Bread bread = new Bread(5, 1, "Whole Wheat"); // 코드 추가
}
}
상속은 중복을 제거하기에 아주 좋은 객체지향 기술처럼 보이고, 그에 따라 상속을 무분별하게 남발하는 경우를 자주 볼 수 있다. 하지만 상속을 이용해야 하는 경우는 상당히 선택적이며, 상속이 갖는 단점은 상당히 치명적이기 때문에 상속보다는 합성을 이용할 것을 권장한다.
- Java의 창시자인 제임스 고슬링(James Arthur Gosling)이 한 인터뷰에서 "내가 자바를 만들면서 가장 후회하는 일은 상속을 만든 점이다" 라고 말할 정도 이다.
- 조슈야 블로크의 Effective Java에서는 상속을 위한 설계와 문서를 갖추거나, 그럴 수 없다면 상속을 금지하라는 조언을 한다.
- 따라서 추상화가 필요하면 인터페이스로
implements
하거나 객체 지향 설계를 할땐 합성(composition)을 이용하는 것이 추세이다.
참조