상속 (Inheritance)
객체지향 프로그램의 핵심 중 하나인 상속은, 기존 클래스를 재사용해서 새로운 클래스를 만드는 자바의 문법 요소를 의미한다. 두 클래스가 서로 상속 관계라고 하면, 상위 클래스의 멤버(필드, 메서드, 이너 클래스)를 하위 클래스에 똑같이 내려주는 것을 뜻한다. 이런 상속 관계는 꼭 두 클래스 간이 아니라, 조상-부모-자식처럼 여러 단계에 걸쳐 이루어질 수도 있다. 때문에 클래스로부터 "확장"됐다고 표현하는 것이 알맞다.
public class InheritanceExample {
int a;
int b;
}
class NoInheritance {
int a;
int b;
int c;
}
// InheritanceExample와 관계 없음. = 변경에 영향을 받지 않음.
class Inheritance extends InheritanceExample {
int c;
}
// InheritanceExample와 상속 관계. = 변경에 영향을 받음.
위 코드는 상속 관계의 유무를 나타낸 코드다. InheritanceExample 클래스와 같은 모양의 클래스에 c라는 필드를 추가하고 싶을 때, NoInheritance 클래스를 새로 만드는 방법이 있다. 기존 멤버에 새 멤버를 추가하는 것인데, 이것은 상속 관계가 아니라 완전히 관계가 없는 새로운 클래스를 작성하는 방법이다.
그러나 코드의 중복을 줄이기 위해 클래스의 멤버를 그대로 물려줘야 할 때가 있다. 이럴 때 하위 클래스 명 뒤에 extends 상위클래스
를 붙여서 작성하면 상위 클래스의 멤버가 그대로 담겨진 클래스를 생성할 수 있다. 중괄호 안에 추가하거나 수정할 멤버를 적으면 된다. 비록 상위 클래스의 멤버인 int a
와 int b
가 보이지는 않지만, 상속 받았기 때문에 Inheritance 클래스의 멤버 개수는 3개다.
public class InheritanceExample {
// int a;
int b;
}
class NoInheritance {
int a;
int b;
int c;
}
class Inheritance extends InheritanceExample {
int c;
}
class Inheritance2 {
public static void main(String[] args) {
Inheritance inheritance = new Inheritance();
inheritance.c = 10;
inheritance.a = 10; // 에러. 부모 클래스의 멤버를 주석처리 했으니 상속 받지 못해 사용이 불가.
inheritance.b = 10;
}
}
주의할 것은 상위 클래스의 멤버에 변경/삭제를 가하면, 하위 클래스에 변경 내용이 고스란히 반영된다는 점이다. 반대로 하위 클래스의 변경이 상위 클래스에 영향을 주지는 않는다.
자바의 객체지향 프로그래밍에서는 단일 상속(Single Inheritance)만 지원한다. 단일 상속이란, 하나의 클래스가 하나의 상위 클래스로부터 상속 받을 수 있다는 의미다. 이에 반해 다중 상속(Multiple Inheritance)은, 하나의 클래스가 여러 상위 클래스로부터 상속을 받을 수 있다는 의미다. 이 다중 상속이 자바에서는 지원이 되지 않는다. 인터페이스를 통해 비슷한 효과를 누릴 수는 있다.
포함 (Composite)
포함은 클래스를 재사용하는 방법으로, 클래스 멤버로 참조변수를 선언하는 것을 의미한다.
public class InheritanceExample {
int a;
int b;
}
class NotComposite {
int a;
int b;
int c;
}
class Composite {
InheritanceExample i = new InheritanceExample();
int c;
}
위 코드를 보면, i라는 참조 변수 선언을 통해 InheritanceExample 클래스를 Composite 클래스에 포함시켰다는 것을 볼 수 있다. 여기서 NotComposite 클래스와 Composite 클래스는 둘 다 멤버가 3개인데, 포함 관계로 인해 둘은 객체를 만들 때 구조적인 차이가 생긴다.
NotComposite로 notcomposite라는 객체를 만들면, notcomposite 객체에 초기값이 0인 a, b, c 3개의 저장공간이 발생한다. Composite로 composite라는 객체를 만들면, 참조 변수 때문에 초기값이 null인 공간과 c 때문에 초기값이 0인 공간이 생긴다. 그리고 i라는 객체를 생성하기 때문에 a, b를 위한 초기값이 0인 공간이 생긴다. 객체가 생성됐기 때문에 null은 인스턴스를 가리키는 공간으로 바뀐다. 따라서 저장공간이 notcomposite 객체와 마찬가지로 총 3개가 만들어지지만, 이 둘은 구조적으로 차이가 있다.
이런 구조적인 차이 때문에 포함 관계를 사용하지 않았을 때보다 사용했을 때, 복잡도가 감소한다. 때문에 포함이란, 작은 단위의 클래스를 만들어 이들을 조합해 클래스를 만드는 것이라고 정의할 수 있다.
그렇다면 언제 상속을 사용하고 언제 포함을 해야 하는가? 가장 간단한 방법은 문장을 만들어보는 것이다. ~에 A, B를 대입해보며 말이 되는 쪽으로 결정하면 된다.
'~은 ~이다.(is-a)'
= 상속'~은 ~을 가지고 있다.(has-a)'
= 포함메서드 오버라이딩 (Method Overriding)
메서드 오버라이딩이란, 상위 클래스로 상속 받은 메서드와 같은 이름의 메서드를 재정의한다는 뜻이다. 파일을 복사하여 붙여 넣을 때 같은 이름의 파일을 덮어쓰기하는 것과 유사하다.
public class Dog {
void sleep() {
System.out.println("강아지가 쿨쿨 잡니다.");
}
}
class Poodle extends Dog {
void sleep() {
System.out.println("푸들이 쿨쿨 잡니다.");
}
public static void main(String[] args) {
Poodle poodle = new Poodle();
poodle.sleep();
}
}
// 출력: 푸들이 쿨쿨 잡니다.
Dog라는 상위 클래스로부터 Poodle이라는 하위 클래스를 작성했다. 하위 클래스에서 sleep()라는 메서드를 재정의하였고, 메서드를 호출했을 때 재정의된 값으로 출력된 것을 확인할 수 있다.
메서드 오버라이딩에는 지켜야 할 세 가지 조건이 있다.
이 세 가지 조건이 반드시 만족되어야 메서드 오버라이딩을 사용할 수 있다. 메서드 오버라이딩을 쓰는 이유는 관리가 편리하기 때문이다.
public class Overriding {
public static void main(String[] args) {
Phone phone = new Phone(); // 각 타입대로 선언 + 객체 생성
Computer3 computer3 = new Computer3();
TV tv = new TV();
phone.on(); // Phone is on.
computer3.on(); // Computer3 is on.
tv.on(); // TV is on.
UpperComputer phone2 = new Phone(); // 상위 클래스 타입으로 선언 + 객체 생성
UpperComputer computer34 = new Computer3();
UpperComputer tv2 = new TV();
phone2.on(); // Phone is on.
computer34.on(); // Computer3 is on.
tv2.on(); // TV is on.
}
}
class UpperComputer {
void on() {
System.out.println("Uppercomputer is on.");
}
}
class Phone extends UpperComputer {
void on() {
System.out.println("Phone is on.");
}
}
class Computer3 extends UpperComputer {
void on() {
System.out.println("Computer3 is on.");
}
}
class TV extends UpperComputer {
void on() {
System.out.println("TV is on.");
}
}
예시를 보면 Phone, Computer3, TV라는 클래스가 각 UpperComputer 클래스로부터 상속을 받아 on() 메서드를 각자에 맞게 오버라이딩 하고 있다. 상위 클래스 타입으로 선언하면 아래 예시와 같은 이점이 있다.
UpperComputer[] upperComputers = new UpperComputer[] {new Phone(), new Computer3(), new TV()};
for (UpperComputer upperComputer : upperComputers) {
upperComputer.on();
} // 출력: Phone is on. Computer3 is on. TV is on.
메서드 오버라이딩을 하면 모든 객체를 클래스 타입 하나로 선언해 배열로 관리할 수 있다. 일일이 객체를 만들거나 메서드를 실행하지 않아도 한꺼번에 동일한 결과를 출력할 수 있어 관리가 굉장히 편리해진다.
super
키워드는 상위 클래스의 객체를 가리킨다. public class Super {
public static void main(String[] args) {
Second s = new Second();
s.callStr();
}
}
class First {
String str = "상위"; // -> super.str
}
class Second extends First {
String str = "하위"; // -> this.str
void callStr() {
System.out.println(this.str); // 출력: 하위
System.out.println(super.str); // 출력: 상위
}
}
이 코드에서 보면 알 수 있듯이, Second 클래스는 First로부터 str를 상속 받는데 이것이 자신의 인스턴스 변수 str와 변수명이 똑같아 구분할 방법이 필요하다. this 키워드와 비슷하게, super라는 키워드를 통해 이 둘을 구분한다. super를 붙이면 상위 클래스의 변수를 참조하여 값을 출력하게 된다.
super()
는 상위 클래스의 생성자를 호출하는 메서드다. public class Super {
public static void main(String[] args) {
Second s = new Second();
}
}
class First {
First() {
System.out.println("First 생성자");
}
}
class Second extends First {
Second() {
super();
System.out.println("Second 생성자");
}
}
// 출력: First 생성자
// Second 생성자
위 코드에서 클래스 Second에 대해 s라는 객체를 생성해 출력했을 때, super() 메서드로 인해 상위 클래스 First의 출력값이 먼저 나오고 Second의 출력값이 나온다. this() 메서드와 마찬가지로 super() 메서드는 생성자 안에서만 사용이 가능하고, 반드시 첫 줄에 작성되어야 한다.
( ✨ 모든 생성자의 첫 줄에는 반드시 this() 또는 super()가 선언되어야 한다. 만약 super()가 없으면 컴파일러가 생성자의 첫 줄에 자동으로 super()이 삽입된다. 그리고 상위 클래스에 기본 생성자가 없으면 에러가 발생한다.)