[윤성우의 열혈 Java 프로그래밍] Chapter 15 - 클래스의 상속 2: 오버라이딩
카테고리: Java lang
태그: java
15-1. 상속을 위한 두 클래스의 관계
- 상속의 기본 조건인 'IS-A 관계'
상위 클래스와 하위 클래스간의 다음 관계가 성립한다.
하위 클래스는 상위 클래스의 모든 특성을 지닌다.
하위 클래스는 자신만의 추가적인 특성을 더하게 된다.
예시를 통해서 상속 관계를 이해해보자.
class MobilePhone {
protected String number;
public MobilePhone(String number) {
this.number = number;
}
public void answer() {
System.out.println("Hi~ from " + number);
}
}
class SmartPhone extends MobilePhone{
private String androidVer;
public SmartPhone(String number, String androidVer) {
super(number);
this.androidVer = androidVer;
}
public void playApp() {
System.out.println("App is running in " + androidVer);
}
}
public class MobileSmartPhone {
public static void main(String[] args) {
SmartPhone phone = new SmartPhone("010-777-7777", "JellyBean");
phone.answer();
phone.playApp();
}
}
‘모바일폰’과 ‘스마트폰’의 관계를 예시로 들어 상속의 특징을 설명하고 있다.
위의 예시를 객체지향적으로 본다면 다음과 같이 얘기할 수 있다.
모바일폰을 스마트폰이 상속한다.
이를 코드를 통해 나타낸 부분은 다음과 같다.
class SmartPhone extends MobilePhone {...}
그런데, 우리는 ‘스마트폰도 모바일폰의 한 종류’라고 말하기도 한다.
따라서 다음과 같이 말하는 것도 가능하다.
스마트폰도 모바일폰이다.
스마트폰은 일종의 모바일폰이다.
그리고 위의 문장들이 나타내는 관계를 가리켜 ‘IS-A 관계’라고 하고,
이것이 상속의 관계를 맺기 위한 두 클래스의 기본 조건이 된다.
- IS-A 관계 정리
- IS-A 관계는 ‘~은 ~이다.’로 표현되는 관계이다.
- 상속이 갖는 문법적 특성은 IS-A 관계의 표현에 적합하다.
- 따라서 상속 관계를 형성하기 위한 두 클래스는 IS-A 관계에 있어야한다.
15-2. 메소드 오버라이딩
메소드 오버라이딩: 상위 클래스에 정의된 메소드를 하위 클래스에서 다시 정의하는 것
문법적으로는 단순하지만 이것이 가져다주는 이점은 가볍지 않다.
인스턴스를 생성 후, 무엇으로 참조하느냐에 따라 인스턴스의 활용능력이 달라진다!
- 상위 클래스의 참조변수가 참조할 수 있는 대상의 범위
위에서 만들었던 예시를 다시 보자..
class MobilePhone {
protected String number;
public MobilePhone(String number) {
this.number = number;
}
public void answer() {
System.out.println("Hi~ from " + number);
}
}
class SmartPhone extends MobilePhone{
private String androidVer;
public SmartPhone(String number, String androidVer) {
super(number);
this.androidVer = androidVer;
}
public void playApp() {
System.out.println("App is running in " + androidVer);
}
}
public class MobileSmartPhone {
public static void main(String[] args) {
// SmartPhone 인스턴스를 각각 SmartPhone과 MobilePhone 타입의 변수로 참조함
SmartPhone phone1 = new SmartPhone("010-777-7777", "JellyBean");
MobilePhone phone2 = new SmartPhone("010-999-9999", "Cookie");
phone1.answer();
phone1.playApp();
System.out.println();
phone2.answer();
// phone2.playApp(); // phone2가 부모클래스형 참조변수라서 컴파일 에러가 남
}
}
이처럼 상위 클래스의 참조변수는 하위 클래스의 인스턴스를 참조할 수 있다. 다음과 같이 정리하자.
- 모바일폰을 상속하는 스마트폰도 일종의 모바일폰이다.
- MobilePhone을 상속하는 SmartPhone 인스턴스는 MobilePhone 인스턴스이기도 하다.
- 따라서 MobilePhone형 참조변수는 SmartPhone 인스턴스를 참조할 수 있다.
여기서 집중해야할 부분은 이 부분이다.
MobilePhone phone2 = new SmartPhone("010-999-9999", "Cookie");
phone2.playApp(); // compile Error!
참조변수 phone2는 MobilePhone 타입의 참조변수이다.
이런 경우 'phone2가 참조하는 인스턴스와는 상관없이'
MobilePhone 클래스에 정의되었거나, 이 클래스가 상속하는 클래스의 멤버로 제한된다.
이처럼 자바는 메소드 호출 시 참조하는 인스턴스와는 관계없이 ‘참조변수의 형(type)을 참조’하여 그 메소드 호출이 옳은 것인지 판단한다.
이에는 2가지 이유가 있는데,
1) 실행 시간을 늦추는 결과로 이어질 수 있다.
실제 참조하는 인스턴스를 대상으로 메소드의 호출 가능성을 판단하는 것은 간단하지 않다.
참조하는 인스턴스의 종류는 코드의 흐름에 따라 얼마든지 달라질 수 있기 때문이다.
2) 참조변수의 형을 기준으로 접근 가능한 멤버를 제한하는 것은 코드를 단순하게 한다.
단점이 많은 일부 기능을 제한함으로써 단순하고 명료한 코드의 작성을 유도했다.
- 클래스의 상속과 참조변수의 참조 가능성에 대한 정리
위의 내용을 정리해보자!
class Cake {
public void sweet() {...}
}
class CheeseCake extends Cake {
public void milky() {...}
}
class StrawberryCheeseCake extends CheeseCake {
public void sour() {...}
}
Cake형의 참조변수 cake1은
-> Cake에 정의된 sweet() 메소드를 호출할 수 있다.
하위 클래스인 CheeseCake형 참조변수 cake2는
-> Cake에 정의된 sweet() 메소드와
-> CheeseCake에 정의된 milky() 메소드를 호출할 수 있다.
- 참조변수 간 대입과 형 변환
Cake 예시를 다시 살펴보자!
class Cake {
public void sweet() {...}
}
class CheeseCake extends Cake {
public void milky() {...}
}
컴파일러는 ‘참조변수의 형’만을 가지고 대입의 가능성을 판단한다.
CheeseCake ca1 = new CheeseCake();
Cake ca2 = c1; // 가능!
‘CheeseCake’ is a ‘Cake’ 이기 때문에 cake형 참조변수로 CheeseCake의 인스턴스를 참조하는 것은 가능하다.
Cake ca3 = new CheeseCake();
CheeseCake ca4 = ca3; // 불가능!
위의 문장을 자바 컴파일러는 다음과 같은 수준으로 보고 대입의 가능성을 판단한다.
Cake ca3 = …
CheeseCake ca4 = ca3;
이렇게 ca3가 참조하는 인스턴스가 cheeseCake의 인스턴스임을 보장할 수 없다.
(Cake를 상속하는 다른 클래스의 인스턴스일 수도 있다..)
다만, 개발자가 명시적으로 형 변환을 해주면 대입이 가능하다.
Cake ca3 = ...
CheeseCake ca4 = (CheeseCake)ca3; // 가능함. 주의 요망!
ca3가 참조하는 인스턴스가 CheeseCake 인스턴스임을 개발자가 보장한다는 의미이다.
이렇게하면 컴파일러는 그냥 허용하기 때문에 치명적인 실수가 발생하지 않도록 주의하여 사용해야한다.
- 메소드 오버라이딩(Method Overriding)
상위 클래스에 정의된 메소드를 하위 클래스에서 다시 정의하는 행위를 메소드 오버라이딩이라고 한다.
class Cake {
public void yummy() { // 오버라이딩 '된' 메소드
System.out.println("Yummy Cake");
}
}
class CheeseCake extends Cake {
public void yummy() { // 오버라이딩 '한' 메소드
System.out.println("Yummy CheeseCake");
}
}
public class YummyCakeOverriding {
public static void main(String[] args) {
Cake cake1 = new CheeseCake();
CheeseCake cake2 = new CheeseCake();
cake1.yummy();
cake2.yummy();
}
}
다음 세 가지가 같아야 메소드 오버라이딩이 성립한다.
- 메소드의 이름
- 메소드의 반환형
- 메소드의 매개변수 선언
실행결과를 보면 알 수 있듯이, 오버라이딩을 하면 참조변수의 형에 상관없이
오버라이딩 한 메소드(CheeseCake의 yummy)가
오버라이딩 된 메소드(Cake의 yummy)를 대신하게 된다.
- 오버라이딩 된 메소드를 호출하는 방법
클래스 외부에서 인스턴스를 통해 오버라이딩 된 메소드를 호출하는 방법은 없다.
다만, 클래스 외부가 아닌 내부에서 메소드를 호출하는 방법은 있다. 다음을 참고하자.
class Cake {
public void yummy() {
System.out.println("Yummy Cake");
}
}
class CheeseCake extends Cake {
public void yummy() {
super.yummy(); // 하위 클래스의 메소드 안에서는 오버라이딩 된 메소드의 호출이 가능하다.
System.out.println("Yummy CheeseCake");
}
public void tasty() {
super.yummy(); // Cake의 yummy 메소드 호출
System.out.println("Yummy Tasty Cake");
}
}
public class YummyCakeSuper {
public static void main(String[] args) {
CheeseCake cake = new CheeseCake();
cake.yummy();
System.out.println();
cake.tasty();
}
}
15-3. instanceof 연산자
연산자 instanceof는 참조변수가 참조하는 인스턴스의 ‘클래스’나 참조하는 인스턴스가 ‘상속하는 클래스’를 묻는 연산자이다.
- instanceof 연산자의 기본
if (ca instanceof Cake) {...}
ca가 참조하는 인스턴스가
- Cake의 인스턴스이거나
- Cake를 상속하는 클래스의 인스턴스이면
true를, 그렇지 않으면 false를 반환한다.
- instanceof 연산자의 활용
다음과 같은 메소드를 정의하고 싶다고 가정하자!
class Box {
public void simpleWrap() {
System.out.println("Simple Wrapping");
}
}
class PaperBox extends Box {
public void paperWrap() {
System.out.println("Paper Wrapping");
}
}
class GoldPaperBox extends PaperBox {
public void goldWrap() {
System.out.println("Gold Wrapping");
}
}
public class Wrapping {
public static void main(String[] args) {
Box box1 = new Box();
PaperBox box2 = new PaperBox();
GoldPaperBox box3 = new GoldPaperBox();
wrapBox(box1);
wrapBox(box2);
wrapBox(box3);
}
public static void wrapBox(Box box) {
if (box instanceof Box) {
box.simpleWrap();
} else if (box instanceof PaperBox) {
((PaperBox)box).paperWrap(); // 형 변환 후 메소드 호출
} else {
((GoldPaperBox)box).goldWrap(); // 형 변환 후 메소드 호출
}
}
}
(추가) 상속의 의미를 생각하며 refactor
위의 wrapping 예제는 상속의 장점을 전혀 살리지 못한 예시라고 할 수 있다.
상속은 연관된 일련의 클래스들에 대해 ‘공통적인 규약’을 정의할 수 있다.
이를 상기하며 코드를 리펙토링 해보자!
class Box {
public void wrap() { // 오버라이딩
System.out.println("Simple Wrapping");
}
}
class PaperBox extends Box {
public void wrap() { // 오버라이딩
System.out.println("Paper Wrapping");
}
}
class GoldPaperBox extends PaperBox {
public void wrap() { // 오버라이딩
System.out.println("Gold Wrapping");
}
}
public class Wrapping {
public static void main(String[] args) {
Box box1 = new Box();
PaperBox box2 = new PaperBox();
GoldPaperBox box3 = new GoldPaperBox();
wrapBox(box1); // Simple Wrapping
wrapBox(box2); // Paper Wrapping
wrapBox(box3); // Gold Wrapping
}
public static void wrapBox(Box box) {
box.wrap();
}
}
wrapBox 메소드를 보면
기존의 instanceof를 활용하여 실제 참조하고 있는 인스턴스가 무엇인지 구분하는 복잡한 코드를
메소드 오버라이딩을 활용하여 간단하게 줄였다.
댓글 남기기