예제 코드는 여기서 보실 수 있습니다.
0. 들어가며
이번 아이템은 상속을 활용해야하는 이유에 대한 내용을 담고있습니다.
아래에서 천천히 알아보겠습니다.
1. 태그 달린 클래스
아이템의 제목에서도 그렇고, 책에서도 태그 달린 클래스
라는 말이 자주 등장하는데요.
그 의미가 궁금해졌습니다.
1.1 태그란?
저는 태그란 클래스를 더 세분화 시키기 위한 필드/상태라고 이해했습니다.
특정 클래스에 이런 상태를 붙여서 더 세분화된 것이 태그 달린 클래스
인 것이죠.
제가 친구들에게 “얼음이 녹으면?” 이라는 질문을 합니다.
그러면 친구들은 다음과 같이 대답합니다.
- 문과 친구 : “얼음이 녹으면 봄이 온다는 거지”
- 이과 친구 : “물이 되지”
그러면 저는 친구라는 클래스에 질문을 했지만, 문과인지 이과인지에 따라 다른 결과가 나올 것 입니다.
즉, 친구는 세분화된 특징에 따라 다르게 동작한 것이죠.
이 내용은 아래 예제 코드를 보면 더 쉽게 이해할 수 있습니다. (추가 설명을 위해 책의 예제와는 조금 다르게 작성했습니다.)
class Shape {
private enum ShapeType { TRIANGLE, RECTANGLE }
private ShapeType shapeType;
private double width;
private double height;
public static Shape createTriangle(double width, double height) {
Shape triangle = new Shape();
triangle.shapeType = ShapeType.TRIANGLE;
triangle.width = width;
triangle.height = height;
return triangle;
}
public static Shape createRectangle(double width, double height) {
Shape rectangle = new Shape();
rectangle.shapeType = ShapeType.RECTANGLE;
rectangle.width = width;
rectangle.height = height;
return rectangle;
}
// 넓이 계산
public double area() {
switch (this.shapeType) {
case TRIANGLE: return (width * height) / 2;
case RECTANGLE: return width * height;
default:
throw new IllegalStateException("지원하지 않는 모양입니다.");
}
}
}
Shape 이라는 클래스는 도형 타입, 정보, 계산기능을 갖고있습니다.
하지만 이 코드가 좋은 코드가 아닐 확률이 아주 높습니다.
그 이유는 객체지향 설계 원칙의 SRP, OCP 를 위반했기 때문입니다.
- SRP 위반 : 삼각형, 사각형의 정보를 담고 넓이를 계산하는 책임을 모두 가지고 있음
- OCP 위반 : 다른 도형을 추가하려면 기존 클래스(Shape)를 수정해야 함
그러면 이 둘을 만족하는 구조로 가기 위해서는 어떻게 해야할까요.
책에서 제시하는 답은 추상화
와 계층구조
를 만들어 상속
을 이용하는것 입니다.
1.2 상속을 해야하는 이유
위의 기존 소스코드는 정리하면 다음과 같은 문제가 있었습니다.
- 장황하다
- 삼각형만 알고싶은데, 사각형의 정보까지 다 들어있어 파악하기 쉽지 않음
- 오류를 내기 쉽다
- 상태에 따른 여러 분기문이 있기 때문에 오류를 만들어내기 더 쉬운 환경임(area 메서드)
- 인스턴스의 타입만으로는 어떤 도형인지 알 수 없다
책에서는 이를 해결하기 위한 방법인 상속
을 제시합니다.
그런데 상속을 하기 위해서는 계층구조를 설계해야합니다.
그래서 우리는 위에서 도출해 낼 수 있는 3가지 개념(Shape, Triangle, Rectangle)의 관계를 정의해야 합니다.
그리고 이 관계를 바탕으로 계층 구조를 설계하고, 이를 코드로 옮기면 되는것이죠.
각 개념들은 다음과 같이 관계를 정리할 수 있습니다.
그리고 이를 소스 코드로 옮기게 되면 다음과 같습니다.
abstract class AbstractShape {
protected final double width;
protected final double height;
public AbstractShape(double width, double height) {
this.width = width;
this.height = height;
}
public abstract double area();
}
class Triangle extends AbstractShape {
public Triangle(double width, double height) {
super(width, height);
}
@Override
public double area() {
return (width * height) / 2;
}
}
class Rectangle extends AbstractShape {
public Rectangle(double width, double height) {
super(width, height);
}
@Override
public double area() {
return width * height;
}
}
공통된 부분을 추상화한 상위 개념(AbstractShape)에 선언하고, 하위 개념에서 구현하는 형태로 되었습니다.
이러한 구조는 다음과 같은 장점이 있습니다.
- 코드가 간결하고 명확함
- 각 클래스에는 필요한 정보들만 있음
- 모든 필드가 final 이기 때문에 불변 객체의 장점도 가짐
- 상태에 따른 분기문이 없어, 오류 발생 확률이 줄어듦
- 인스턴스의 타입만 봐도 어떤 도형인지 알 수 있음
- 그리고 코드 중복을 줄여줌
- width, height 등을 AbstractShape 에 선언하여 사용
그리고 이전 코드는 SRP, OCP 를 위반했지만 이 코드는 이 두 원칙을 지키고 있습니다.
- SPR
- AbstractShape : 도형의 추상화 개념, 넓이 기능 제공
- Triangle : 삼각형에 대한 넓이 기능을 구현
- Rectangle : 사각형에 대한 넓이 기능을 구현
- OCP
- 다른 도형이 추가되어도 AbstractShape 을 상속한 다른 클래스를 만들면 됨
(기존 클래스들에는 전혀 영향을 주지 않음)
- 다른 도형이 추가되어도 AbstractShape 을 상속한 다른 클래스를 만들면 됨
1.3 상속을 하지 말아야 하는 이유
책에서는 1.2 까지의 내용으로 끝나지만, 상속에 대해 조금 더 짚어봐야할 얘기들이 있습니다.
그것은 바로 상속이 만능은 아니라는 것 입니다.
관련된 내용은 아이템 18번에서 상속이 야기하는 문제상황들을 통해 확인할 수 있었는데요.
그러면 어떤 경우에 상속을 하지 말아야 할까요?
1) 코드 중복 제거
이번 예제처럼 코드 중복을 제거하기 위한 상속 구조는 지양하는것이 좋습니다.
위 예제에서의 클래스들을 간단히 정리해보면 다음과 같습니다.
- AbstractShape
- width
- height
- AbstractShape(width, height)
- Triangle extends AbstractShape
- area()
- Rectangle extends AbstractShape
- area()
Triangle, Rectangle 에서 선언되어 사용되던 중복된 두 필드 선언을 AbstractShape 으로 옮겼었습니다.
이 덕분에 Triangle, Rectangle 은 ‘중복된 코드가 제거되어’ 더 간결하고 깔끔한 코드가 되었는데요.
하지만 중복 제거를 위해 상속을 이용한 경우에는 더 큰 문제를 야기할 수 있습니다.
2) 새 클래스 추가
Circle 이라는 새로운 클래스가 필요해졌습니다.
이 클래스는 width, height 는 사용하지 않고, 대신 radius 라는 필드를 필요로 합니다.
이렇게 변하게 된 AbstractShape, Circle 의 코드는 다음과 같습니다.
abstract class AbstractShape {
// 더 이상 불변 객체가 아님
protected double width;
protected double height;
protected double radius;
// 상속을 사용하기 전과 똑같은 구조의 생성자
public AbstractShape(double radius) {
this.radius = radius;
}
// 상속을 사용하기 전과 똑같은 구조의 생성자
public AbstractShape(double width, double height) {
this.width = width;
this.height = height;
}
public abstract double area();
}
class Circle extends AbstractShape {
// 여기서는 radius 만 필요하지만 불필요한 필드인 width, height 모두 접근 가능함
public Circle(double radius) {
super(radius);
}
public double area() {
return Math.PI * (radius * radius);
}
}
단지 Circle 클래스만 추가하고 싶었을 뿐인데, 이전의 동일한 문제들이 다시 생겨났습니다.
그래서 이를 해결하기 위해서 다시 코드를 수정합니다.
abstract class AbstractShape {
public abstract double area();
}
class Triangle extends AbstractShape {
private final double width;
private final double height;
public Triangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return (width * height) / 2;
}
}
class Rectangle extends AbstractShape {
private final double width;
private final double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
}
class Circle extends AbstractShape {
private final double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * (radius * radius);
}
}
이렇게 각자 필요한 필드를 관리하고 공통된 기능(area)을 구현하는데 신경을 쓰면 됩니다.
모든 클래스들은 불변이며 SRP, OCP 를 지키는 좋은 구조가 되었습니다.
그리고 여기서 한단계 더 나아간다면, 다음과 같이 변경할 수 있습니다.
interface Area {
double area();
}
class Triangle implements Area {
...
}
class Rectangle implements Area {
...
}
class Circle implements Area {
...
}
Shape 를 Area 라고 변경하면, 다중상속을 통해 다른 기능도 추가할 수 있는 유연한 구조가 가능해 집니다.