예를 들어 Pet이라는 타입은 모든 애완동물을 아우르는 타입이지 자체적으로 객체를 생성하기 위해 만들어진 타입은 아니다. 객체지향에서 객체를 생성하지 못하는 클래스를 추상 클래스(abstract class)라 한다.
자바에서는 abstract 키워드를 클래스를 정의할 때 수식하여줌으로써 추상 클래스를 정의할 수 있다
추상 클래스에는 공통 리모컨 용도로 정의되어야 하는 메소드이지만 하위 클래스가 공유할 수 있는 의미 있는 구현을 포함할 것이 없는 경우가 있다. 이때 빈 메소드로 정의하는 대신에 메소드를 선언만할 수 있다. 이 경우 해당 메소드도 abstract 키워드로 수식해 주어야 하며, 이처럼 클래스에 선언만 되어 있는 메소드를 추상 메소드라 한다
클래스에 추상 메소드를 포함하면 하위 클래스는 이를 반드시 구현해야 한다. 구현하지 않으면 하위 클래스도 추상 클래스가 될 수밖에 없다. 이 측면에서 빈 메소드, 예외처리, 추상 메소드를 이용하는 3가지 방법의 차이를 이해할 필요가 있다. 추상 메소드를 가지는 클래스는 반드시 추상 클래스로 정의되어야 하며, 추상 메소드가 없어도 추상 클래스로 만들어 객체를 생성 못하게 할 수 있다.
그림 4.6과 같이 하위 객체를 생성할 때에는 하위 객체 생성자에서는 해당 클래스에 정의된 멤버 변수만 초기화하고 상위 객체로부터 물려받는 멤버 변수의 초기화는 상위 클래스의 생성자를 호출하여 초기화하여야 한다. 이때 사용하는 것이 super 키워드이다. super 키워드를 사용한 호출은 this 키워드를 이용한 호출과 마찬가지로 생성자 몸체의 첫 문장이어야 한다. 따라서 super 키워드와 this 키워드를 이용한 생성자 호출을 동시에 이용할 수 없다. 이 때문에 Student 클래스에 여러 개 생성자를 정의할 경우 이 중 하나만 super를 이용하고 나머지는 아래 코드와 같이 this를 이용하도록 하여 코드 중복을 줄일 수 있다.
public class Person{
private String name;
public Person(String name) {
this.name = name;
}
}
public class Student extends Person{
private String number ; // 학번
private int year ; // 학년
public Student ( String name, String number) {
this (name,number, 1 ) ;
}
public String ( String name, String number, int year ) {
super (name) ;
this.number = number ;
this.year = year ;
}
}
상속 관계 또는 구체화 관계를 활용하는 가장 큰 이유 중 하나는 상위 타입을 공통 리모컨으로 활용하기 위함이다. 상속 계층도 또는 구체화 계층도에서 하위 타입의 객체는 상위 타입에 타입 변환 없이 대입할 수 있다. 예를 들어 Pet이 Dog의 부모 클래스이면 당연한 것이지만 다음이 가능하다.
Pet p = new Dog( ) ;
하지만 가끔 부모 타입에 유지된 변수를 원래 타입으로 변환해야 하는 경우가 있다. 이 경우에는 타입 변환하기 전에 타입 변환이 가능한지 검사하지 않으면 예외가 발생할 수 있다. 예를 들어 Cat과 Dog이 Pet의 자식 클래스일 때 위 문장의 p를 Cat으로 다음과 같이 변환하면 예외가 발생한다.
Cat c = (Cat)p;
타입의 호환 여부는 instanceof 연산자를 다음과 같이 이용하여 확인할 수 있다.
if (p instanceo f Cat) {
Cat c = (Cat)p;
. . .
}
위 코드에서는 절대 예외가 발생할 수 없다. 참고로 instanceof를 이용하여 검사하는 객체참조변수가 null이면 NullPointerException이 발생하지 않고 false로 평가한다.
추가 : instanceof는 호환 여부만 검사하기 때문에 정확한 타입을 검사할 때 사용하는 것은 적절하지 않다. Pet이 Dog의 부모 클래스이고 Dog이 ShihTzu의 부모 클래스일 때 다음 코드가 실행되면 모두 true가 출력된다.
Dog d = new ShihTzu( ) ; System. out.println(d instanceof ShihTzu) ; System. out.println(d instanceof Dog) ; System. out.println(d instanceof Pet ) ;
참고로 정확한 타입을 검사하고 싶으면 Object에 정의되어 있는 getClass 메소드를 이용한다.
자식 클래스에서 메소드를 정의할 때 가능한 시나리오는 다음과 같다.
조상 클래스에 정의된 모든 메소드는 자동으로 상속된다. 하지만 접근 권한이 없는 메소드는 하위 클래스에서 사용할 수 없고, 당연히 해당 객체 변수를 통해서도 접근할 수 없기 때문에 접근 권한이 없는 메소드는 상속되지 않는다고 하여도 틀린 말은 아니다. 보통 자식 클래스의 공개 메소드 목록은 부모의 공개 메소드 목록과 같은 경우가 가장 이상적이다. 이 경우 부모 리모컨과 자식 리모컨에 있는 버튼이 동일하다는 것이다. 만약 위 경우 3처럼 부모 클래스에 없는 새 메소드를 정의하였고 이것이 외부에서 접근 가능한 메소드이면 오직 해당 타입의 리모컨만을 이용하여 호출할 수 있다. 이것은 앞서 설명한 상속의 장점 중 하나인 공통 리모컨에 대한 효과를 반감시키는 문제점이 있다. 또 재정의할 경우 부모 타입에서 해당 메소드의 의미와 자식 타입에서 해당 메소드의 의미가 확연하게 다르면 상속으로 모델링하는 것이 적절하지 않을 수 있다.
자식 클래스에 멤버 변수를 정의할 때 가능한 시나리오는 다음과 같다.
자식 클래스에 새 멤버 변수를 정의함에 따라 이 변수를 사용하는 부모에는 없는 새 공개 메소드가 정의되는 것은 앞서 언급한 것처럼 부모 리모컨을 통해 자식의 모든 기능을 사용할 수 없기 때문에 바람직한 것은 아니다. 새 맴버 변수가 메소드의 재정의에서 사용되는 것이라면 가장 적절한 형태가 될 것이다. 동일 이름의 메소드를 정의하여 부모에 정의된 메소드를 재정의할 수 있지만 같은 이름의 멤버 변수를 정의하는 것은 같은 이름의 멤버 변수가 두 개 존재하는 형태가 되므로 여러 가지 부작용을 초래할 수 있기에 적절한 프로그래밍 방법은 아니다.
자식에서 부모에 정의된 메소드와 다른 행동을 해야 할 경우 해당 메소드를 재정의한다. 이때 다음 규칙을 지켜야
한다.
• 규칙 1. 매개변수 목록의 타입들은 정확하게 일치해야 한다.
• 규칙 2. 반환 타입은 더 특수한 타입을 사용할 수 있다.
• 규칙 3. 메소드의 접근 권한은 완화할 수 있어도 더 강화할 수 없다.
• 규칙 4. 부모보다 더 많은 checked 예외를 공표할 수 없다.
모든 규칙은 부모 타입의 리모컨을 이용하여 객체를 조작할 때 모순이 없도록 하기 위해 필요한 규칙이다
참고로 @Override는 컴파일러에게 정의되는 메소드는 재정의하는 메소드임을 알려줘 더 엄격한 검사를 할 수 있도록 해주는 어노테이션(annotation)이다. 자바는 자바5부터 이와 같은 어노테이션 도입하여 사용할 수 있도록 하였다.
규칙 1과 규칙 2는 논리적 측면도 고려할 필요가 있다. 규칙 1과 관련하여 메소드의 사전 조건은 강화되지 않아야 하고, 규칙 2와 관련하여 메소드의 사후 조건은 약화되지 않아야 한다. 예를 들어 부모에 정의된 메소드의 정수 인자는 음수도 처리할 수 있지만 자식애서 재정의한 메소드는 같은 인자에 대해 양수만 요구하면 적절하지 않다는 것이다. 거꾸로 부모에 정의된 메소드는 양의 정수만 반환하는데 자식에서 재정의된 메소드는 음수도 반환할 수 있으면 적절하지 않은 것이다.
#CheckingAccount 클래스
public class CheckingAccount extends BankAccount{
private int transactionCount = 0 ;
. . .
@Override public void deposit ( int amount) {
super.deposit(amount);
++transactionCount ;
}
}
자식 클래스에서 메소드를 재정의할 때 부모 클래스의 같은 이름의 메소드를 호출해야 하는 경우가 많다. 예를 들어 BankAccount를 상속받는 CheckingAccount의 경우 입금과 인출이 이루어진 횟수를 계산하고 이를
바탕으로 수수료를 지불해야 한다고 가정하자. 즉, 기존 입금과 인출은 그대로 사용 가능하지만 추가로 횟수를 계산해야 한다. 이를 위해 그림 4.7처럼 CheckingAccount의 입금 메소드를 정의할 수 있다. CheckingAccount의 deposit 메소드는 그것의 부모와 같은 이름의 메소드를 호출하기 위해 super라는 키워드를 사용하였다. 이처럼 자식과 부모에 같은 이름의 멤버가 있을 때 이들을 구분하기 위해 super라는 키워드를 사용할 수 있다. 하지만 이 키워드는 this와 큰 차이가 있다. this는 현재 객체를 가리키는 참조값이지만 super는 단순하게 구분하기 위한 식별자이다. 그뿐만 아니라 super는 부모에 있는 멤버를 접근하기 위해서만 사용할 수 있다.
우리가 방에 들어갈 때 출입하는 문을 객체지향으로 모델링하여 보자. 단순하게 문의 상태는 열려 있는지 닫혀있는지만 유지하면 되며, 문이 할 수 있는 행위는 문을 열고 닫는 것이다. 이와 같은 문은 <코드 1 - Door 클래스>처럼 구현할 수 있다. 여기서 isOpen 멤버 변수를 명백한 초기화를 한 이유는 항상 문은 생성될 때 닫힌 상태로 생성된다는 것을 문서화하기 위함이다. 실제 open과 close 메소드 내의 if문과 System.out.println문은 모두 불필요한 요소이지만 올바르게 동작한다는 것을 확인하기 위해 추가한 것이다. 참고로 자바는 멤버 변수 이름과 메소드 이름이 같아도 문제가 되지 않는다. 이 Door 클래스를 잘 사용하다가 잠금 장치가 있는 문이 필요하게 되었다. 이때 어떻게 하는 것이 바람직한가?
# <코드 1 - Door 클래스>
public class Door {
private boolean isOpen = false;
public Door () {}
public void open( ) {
if (isOpen) System.out.println("문이 열려 있음");
else{
System.out.println("문이 열림");
isOpen = true ;
}
}
public void close () {
if (isOpen) {
System.out.println("문이 닫힘");
isOpen = false;
}
else System.out.println("문이 이미 닫혀 있음");
}
}
- 크게 방법 3가지가 있다.
- 문 클래스를 수정하여 잠금장치를 가질 수 있도록 수정하는 것
- 잠금장치가 있는 문도 문이기 때문에 기존 문을 상속하여 잠금장치가 있는 문을 만들 수 있다.
- 기존 문을 포함 관계로 이용하여 잠금장치가 있는 문을 만들 수 있다.
객체지향 방법을 고려하면 상속하여 구현하는 것이 적절하다고 생각할 수 있다. 상속을 이용하여 구현한 결과가 <코드 2 - DoorwithLock 클래스> 과 같다고 하자. 이와 같이 구현하여 실행하면 올바르게 동작하지 않는다. 그 이유는 lock과 unlock은 잠금 장치의 상태뿐만 아니라 문이 잠겨 있는지까지 고려하여야 한다. Door는 isOpen 상태를 private으로 정의하고 있기 때문에 DoorWithLock에서는 직접 접근이 가능하지 않다. <코드 1 - Door 클래스>에 다음과 같은 메소도가 포함되어 있지 않은 것은 실수라고 보아야 한다.
public boolean isOpen ( ) {
return isOpen ;
}
# <코드 2 - DoorwithLock 클래스>
public class DoorWithLock extends Door {
private boolean isLocked = false;
public DoorWithLock() {}
public void lock () {
if(isLocked) System.out.println("이미 잠겨 있음");
else{
System.out.println("문을 잠금");
isLocked = true ;
}
}
public void unlock(){
if(isLocked) {
System.out.println("잠금을 해제함");
isLocked = false;
}
else System.out.println("문이 잠겨 있지 않음");
}
}
<코드 2 - DoorwithLock 클래스>의 오류들을 수정하면 <코드 3 - 수정된 DoorwithLock 클래스>와 같다. lock 메소드만 수정되어야 하는 것은 아니다. open 메소드도 isLocked 상태를 사용하도록 재정의되어야 한다. 하지만 이 예에서도 알 수 있듯이 부모 있는 메소드를 재정의할 때 부모에 정의된 코드를 무시하는 경우도 드물다. 보통 메소드를 재정의할 때 부모 메소드를 호출한 다음에 추가작업을 하거나 추가 작업 후 부모 메소드를 호출하여 마무리하는 경우가 많다.
# <코드 3 - 수정된 DoorwithLock 클래스>
public class DoorWithLock extends Door {
private boolean isLocked = false;
public DoorWithLock() {}
@Override
public void open() {
if (isLocked) System.out.println("잠겨 있어 열 수 없음");
else super.open();
}
public void lock () {
if (isOpen()) System.out.println("문이 열려 있어 잠글 수 없음");
else if (isLocked) System.out.println("이미 잠겨 있음");
else {
System.out.println("문을 잠금");
isLocked = true ;
}
}
public void unlock (){
if(isLocked){
System.out.println("잠금을 해제함");
isLocked = false;
}
else System.out.println("문이 잠겨 있지 않음");
}
}
<코드 3 - 수정된 DoorwithLock 클래스>는 이제 오류 없이 올바르게 동작하지만 사용 측면에서 문제가 있다. DoorWithLock 클래스에는 부모에 없는 두 개의 공개 메소드 lock과 unlock이 추가되었기 때문에 Door 타입으로는 DoorWithLock 객체를 조작하기가 어렵다.
다른 측면에서 보면 상속 계층도에서 중간에 있는 클래스도 객체를 생성해야 하고, 단말에 있는 클래스도 객체를 생성해야 하는 형태가 되었다. 즉, 잠금장치가 없는 문이 필요할 때에는 Door 객체를 생성하고, 잠금장치가 있는 문이 필요할 때에는 Door 객체의 자식인 DoorWithLock 객체를 생성해야 한다. 이것은 처음 Door를 설계할 때 확장에 대한 생각없이 지금 현재에 필요한 기능만 구현하였기 때문이다. 이처럼 객체지향이라고 모든 문제를 쉽고 유연하게 해결해주는 것은 아니다. 특히 상속에 대한 고려없이 설계된 클래스를 상속하여 새 클래스를 정의할 때에는 여러 가지 문제가 발생할 수 있다. 문 예제는 이 측면을 강조하기 위해 소개된 예이다.
처음부터 잠금장치가 없는 문과 잠금장치가 있는 문을 모두 고려하여 설계하였다면 <코드 4>과 같이 설계할 수 있다.
# <코드 4 - Door, DoorwithoutLock, DoorwithLock 클래스>
public abstract class Door {
private boolean isOpen = false;
public Door () {}
public boolean isOpen () { return isOpen; }
public void open( ) { . . . }
public void close ( ) { . . . }
public abstract void lock ( ) ;
public abstract void unlock ( ) ;
}
public class DoorWithoutLock extends Door{
public DoorWithoutLock() {}
@Override public void lock () {}
@Override public void unlock () {}
}
public class DoorWithLock extends Door {
private boolean isLocked = false;
public DoorWithLock() {}
@Override public void open() { . . . }
@Override public void lock () { . . . }
@Override public void unlock () { . . . }
}
이 경우 Door 타입을 이용하여 DoorWithoutLock과 DoorWithLock을 모두 처리하기 위해 lock과 unlock 메소드를 Door 클래스에 추상 메소드로 선언하였다. 따라서 Door 자체도 추상 클래스가 되어야 한다. DoorWithoutLock은 lock과 unlock을 상속받기 때문에 이것을 재정의하지 않으면 DoorWithoutLock도 추상 클래스가 된다. 그런데 DoorWithoutLock은 lock과 unlock이 필요 없다. 이처럼 어떤 클래스가 부모 클래스를 상속받게 되면 자신이 원하는 일부 메소드들만 취사선택하여 상속받을 수 없다. 보통 상속받을 필요가 없는 메소드가 부모 클래스에 있으면 이것의 실행이 문제를 일으키지 못하도록 하기 위해 빈 메소드로 재정의할 수 있다. 즉, DoorWithoutLock에서 lock과 unlock을 빈 메소드로 정의한 것은 이들 메소드가 호출되어도 아무 문제가 발생하지 않도록 하기 위함이다.
잠금 장치가 있는 문을 상속을 통해 정의하지 않고 포함 관계를 이용할 수 있다. 포함 관계를 이용한 잠금 장치 문은 <코드5>과 같다. 이처럼 기존 클래스가 있을 때 해당 클래스를 수정하지 않고 확장하는 방법은 크게 상속을 이용하는 방법과 포함 관계를 이용하는 방법이 있다. 포함 관계를 이용한 방법은 Door와 DoorWithLock을 함께 처리할 수 있는 공통 리모컨이 없다. interface를 이용하여 공통 리모컨을 정의할 수 있지만 이 경우 기존 Door에 대한 수정이 불가피하다.
# <코드 5 - 포함관계를 이용한 DoorwithLock 클래스>
public class DoorWithLock{
private Door door = new Door ();
private boolean isLocked = false;
public DoorWithLock() {}
public boolean isOpen ( ) {return door.isOpen();}
public void open( ) {
if (isLocked) System.out.println("잠겨있어 열 수 없음");
else door.open();
}
public void close () {door.close();}
public void lock () {
if (door.isOpen()) System.out.println("문이 열려 있어 잠글 수 없음");
else if (isLocked) System.out.println("이미 잠겨 있음");
else{
System.out.println ("문을 잠금");
isLocked = true ;
}
}
public void unlock () {
if (isLocked) {
System.out.println("잠금을 해제함") ;
isLocked = false;
}
else System.out.println ("문이 잠겨 있지 않음") ;
}
}
...
객체지향은 기존 코드를 수정하지 않고 확장할 수 있다는 장점이 있지만 이 장점이 효과를 발휘하기 위해서는 처음부터 확장에 대한 충분한 고려가 있어야 한다. 지금 살펴본 문 예제처럼 확장에 대한 고려없이 설계 및 구현된 클래스를 효과적으로 확장하는 것은 쉽지 않으며, 원래 클래스에는 수정이 불가피할 수 있다.
고려사항 1에서 is-a가 성립하더라도 부모와 자식 간에 같은 메소드의 논리적 의미가 매우 상이할 경우에는 상속으로 모델링하는 것이 적절하지 않을 수 있다. 고려사항 2에서 클래스가 물려받는 메소드가 자신에게 맞지 않을 때 재정의할 수 있고, 빈 메소드나 예외 처리를 통해 해결할 수 있다. 만약 여러 형제 클래스가 빈 메소드나 예외 처리로 해당 메소드를 사용할 수 없도록 할 경우에는 이들을 아우르는 중간 클래스를 정의하고 해당 클래스에서 이 메소드를 빈 메소드로 처리할 수도 있다. 하지만 이와 같은 해결이 올바른 해결책이 아니라 고려사항 1에 대한 보충 설명에서 언급한 것과 같이 설계 자체가 잘못된 것일 수 있다.