상속은 기존의 클래스를 재사용하여 새로운 클래스를 작성하는 것으로, 기존의 코드를 재사용할 수 있으며 중복을 줄일 수 있다.
extends 키워드를 사용하여 상속을 하며 멤버만 상속된다. 즉, 조상 클래스의 멤버를 자식 클래스가 상속하면 사용할 수 있는 것이다. 그래서 "확장"의 의미인 extends를 사용하는 것이라 할 수 있다.
상속에 대한 정리
1. 생성자와 초기화 블럭은 상속되지 않는다. 멤버만 상속된다.
2. 자식 클래스의 멤버의 개수는 조상 클래스보다 항상 많거나 같다.
3. 조상 클래스의 인스턴스를 생성하지 않아도 조상 클래스의 멤버 사용이 가능하다.
4. 모든 클래스의 최고 조상은 Object 클래스이다.
*모든 클래스는 Object 클래스를 상속하기 때문에 toString()이나 equals(Object o)와 같은 메서드를 따로 정의하지 않고 사용가능한 것이다
class Parent{
int x;
int y;
void method1(){
System.out.println("Inheritance");
}
Parent(){
System.out.println("생성자는 상속되지 않는다");
}
{int x = 4;
System.out.println("초기화 블럭은 상속되지 않는다");
}
class Child extends Parent{
int method2(){
return x + y;
}
{ x = 1; y = 2;} //자식 클래스의 초기화 블럭(조상 클래스의 멤버 초기화)
}
public class Inheritance {
public static void main(String[] args) {
Child c = new Child();
c.method1();
System.out.println(c.method2());
}
}
초기화 블럭은 상속되지 않는다
생성자는 상속되지 않는다
Inheritance
3
하지만, 결과를 보면 조상 클래스의 생성자와 초기화 블럭이 실행된 것을 알 수 있다. 생성자와 초기화 블럭은 상속되지 않는 것은 맞다. 하지만, 자식 클래스의 인스턴스 생성 시 우선적으로 조상 클래스의 인스턴스가 생성되고 자식 클래스의 인스턴스가 생성된다. 그래서 조상 클래스의 초기화 블럭과 생성자가 실행된 것이다.
한 개의 조상 클래스를 여러 개의 자식 클래스가 상속하였을 경우 자식 클래스 간의 관계가 있을까? 동일한 클래스를 상속하여도 자식 클래스들끼리는 무관하다. 하지만, 조상 클래스가 변경되었을 경우 공통적으로 적용된다.
class Parent{
}
class Child1 extends Parent{
}
class Child2 extends Parent{
}
// Child1 과 Child2는 아무 관계가 없다.
이러한 특성으로 여러 클래스에 공통적으로 추가해주어야 할 멤버가 있다면 각각 따로 추가해주는 것보다 공통 조상 클래스에 추가해주는 것이 좋다.
extends 키워드를 사용하여 어느 한 클래스를 상속할 경우 두 클래스는 상속관계이지 포함관계가 아니다. 클래스를 재사용하는 방법으로 상속 외에도 포함관계를 맺어주는 것이 있다.
*흔히 is-A , has-A 이런식으로 부른다
예를 들어, 사람과 생물은 상속관계에 비유할 수 있다. 사람은 생물이다 라는 명제가 성립한다.
하지만, 자동차와 바퀴의 경우 바퀴는 자동차다 라는 명제가 성립할 수 없다. 자동차는 바퀴를 가지고 있다 라고 하는 것이 옳다. 이러한 비유가 포함관계라고 할 수 있다.
포함관계는 멤버변수로 다른 클래스 타입의 참조변수를 선언하는 것을 말한다.
class ID{
int age;
int height;
int weight;
ID(int age, int height, int weight){
this.age = age;
this.height = height;
this.weight = weight;
}
}
class Employee{
ID id; // ID 클래스 타입의 참조변수를 매개변수로 선언
String name;
void SetIdentity(int age, int height, int weight, String name) {
this.id = new ID(age,height,weight); //ID 클래스 생성자에 매개변수 입력
this.name = name;
}
void GetIdentity() {
System.out.println("age : "+ id.age);
System.out.println("height : "+ id.height);
System.out.println("weight : "+ id.weight);
System.out.println("name :"+ name);
}
}
public class Document {
public static void main(String[] args) {
Employee e = new Employee();
e.SetIdentity(27, 179, 96, "Kim");
e.GetIdentity();
}
}
age : 27
height : 179
weight : 96
name :Kim
포함관계를 알아야 하는 이유는 자바 특성상 단일상속만 가능하기 때문이다.
다중상속이 단일상속에 비해 복합적인 기능을 가진 클래스를 만들 수 있는 장점이 있지만, 이름이 겹치는 메서드가 많아지면 이를 구별하기 위해 모든 클래스의 메서드명을 변경해야 하는 단점이 있다. 자바는 이 단점을 없애기 위해 장점을 포기하였고 그 결과로 클래스는 단일상속만 허용한다.
포함관계를 사용하면 단일상속의 한계점을 극복할 수 있기 때문에 클래스를 만들 때 이를 고려하면 더 간결하고 객체지향적인 코드 작성이 가능하다!
앞에서 메서드에 대해 공부할 때 오버로딩과 오버라이딩에 차이점에 대해 간략히 알아보았다. 오버라이딩이 성립하기 위해서는 다음 조건들이 성립해야 한다.
오버라이딩 조건
1. 메서드명이 같아야 함
2. return type이 같아야 함
3. 매개변수가 같아야 함
A클래스가 B클래스를 상속할 때 두 클래스에 오버라이딩 조건에 부합되는 메서드가 있을 경우 A클래스의 메서드로 내용이 변경된다. 예제를 통해 구체적으로 알아보자.
class Parent{
void method() {
System.out.println("부모 클래스");
}
}
class Child extends Parent{
void method() {
System.out.println("자식 클래스");
}
}
public class Override {
public static void main(String[] args) {
Child c = new Child();
c.method();
}
} // "자식 클래스" 출력
여기서 Child 클래스는 Parent 클래스를 상속하기 때문에 Parent 클래스의 method도 사용이 가능하다. 하지만, Child 클래스에도 void 타입의 메서드명이 method, 매개변수가 없는 메서드가 존재하기 때문에 오버라이딩이 성립하여 내용이 변경된다.
예전에 자바 공부했을 때는 모르고 지나갔던 부분인데 이거는 짚고 넘어가야겠다.
오버라이딩에서 접근 제어자와 예외에 대한 오버라이딩 성립 조건이 존재한다.
- 자식 클래스 메서드의 접근 제어자는 조상 클래스 메서드보다 범위가 넓거나 같아야 한다
- 자식 클래스의 메서드는 조상 클래스의 메서드보다 예외의 수가 많을 수 없다
class Parent{
protected void parentmethod(){
}
}
class Child extends Parent{
private void parentmethod(){
} // 오버라이딩 성립 불가
}
여기서 오버라이딩이 성립되려면 자식 클래스의 메서드 접근 제어자가 protected, public만 가능하다. 반대의 경우는 성립이 되지 않는다.(컴파일 에러가 발생함)
class Parent{
void parentmethod() throws IOException,SQLException{
}
}
class Child extends Parent{
void parentmethod() throws Exception{
} // 오버라이딩 성립 불가
}
두 번째로 자식 클래스 메서드의 예외가 더 개수가 적어보이지만 그렇지 않다. Exception은 모든 예외의 최상위이기 때문에 모든 예외를 포함한다. 실질적으로는 더 많은 것이다. 따라서 오버라이딩이 성립되지 않는다.
오버라이딩이 성립하지 않으면 컴파일러 입장에서는 구별되지 않는 같은 메서드가 여러 개 있는 것이기 때문에 컴파일 에러가 발생한다. 따라서, 메서드명을 변경하거나 오버라이딩 성립 조건에 부합하게 하는 방법 밖에 없다.
마지막으로, 클래스 메서드도 오버라이딩이 가능할까?
정답은 "불가하다" 이다.
생각을 해보자. 클래스 메서드는 인스턴스 생성 없이 "클래스명.메서드명"으로 호출한다. 즉, 클래스명으로 메서드의 구별이 가능하다는 것이다. 따라서, 메서드명이 같아서 클래스 메서드인 경우 컴파일 에러가 발생하지 않으며 오버라이딩 또한 불가하다.
class Parent{
static void parentmethod() {
System.out.println("parent");
}
}
class Child extends Parent{
static void parentmethod() {
System.out.println("child");
}
}
public class staticmethodoverride {
public static void main(String[] args) {
Parent.parentmethod();
Child.parentmethod();
}
}
이처럼 결국 메서드 호출을 위해서는 클래스명을 호출해야 하기 때문에 구별점이 생길 수 밖에 없다.
생성자에서 this와 this()가 언급된 적이 있다. 인스턴스 메서드의 매개변수명과 클래스의 멤버변수명이 중복될 경우 이를 구분하기 위해 this를 추가하여 인스턴스를 가리키는 기능으로 사용하였다. 비슷한 맥락으로 조상 클래스의 멤버변수와 동일한 이름의 멤버변수가 자식 클래스에 있다면 어떻게 구분할 것인가?
class Parent{
int x = 1;
int y = 2;
}
class Child extends Parent{
int x = 3;
int y = 4;
void method() {
System.out.println("x = " + x); //3
System.out.println("this.x = " + this.x); //3
System.out.println("super.x = " + super.x); //1
}
}
public class superFunction {
public static void main(String[] args) {
Child c = new Child();
c.method();
}
}
여기서 this는 자식 클래스를, super는 부모 클래스의 인스턴스 주소를 가리킨다고 생각하면 된다. 멤버변수는 오버라이딩이 이루어질 수가 없기 때문에 이름이 중복되면 구별할 수가 없다.
따라서, 위의 예시에서는 그냥 x,y이면 자식 클래스의 멤버변수를 가리키는 것이고 this를 추가하면 마찬가지이다.
상속한 부모 클래스의 멤버변수를 가리키고 싶으면 super를 추가하여 부모 클래스의 인스턴스 주소를 가리키면 된다.
멤버변수뿐만 아니라 메서드에도 적용할 수 있다. 다음 예제를 보자.
class Parent{
int x = 1;
int y = 2;
String method() {
return "x : " + x + ", " + "y : " + y;
}
}
class Child extends Parent{
int x = 3;
int y = 4;
String method() {
return super.method();
}
}
public class example {
public static void main(String[] args) {
Child c = new Child();
System.out.println(c.method());
}
}
method()는 오버라이딩이 발생하여 자식 클래스의 메소드가 호출될 것이다. 하지만, 자식 클래스 메소드 내에서 부모 클래스를 호출하는 메서드가 포함되어있다. 코드를 실행하면 x = 1, y = 2로 출력이 되는데 여기서 의문이 생긴다.
왜 자식 클래스의 멤버변수 값이 아닌 부모 클래스의 멤버변수 값으로 출력될까?
부모 클래스의 메서드가 정의된 곳에서는 x와 y가 부모 클래스의 멤버변수를 가리키기 때문이다.
본론으로 돌아와서, 부모 클래스의 메서드를 오버라이딩 한 경우에도 별도로 부모 클래스 메서드를 호출하고 싶을 경우 super를 사용하면 된다.
this()와 마찬가지로 super() 역시 생성자이다. this()의 경우 같은 클래스의 다른 생성자를 호출하지만, super()는 조상 클래스의 생성자를 호출한다.
자식 클래스의 인스턴스를 생성하면 자동으로 자손과 조상의 멤버가 합쳐진 하나의 인스턴스가 생성된다. 이 때, 조상 클래스 멤버의 초기화가 이루어져야 하기 때문에 자손 클래스의 생성자에서 조상 클래스의 생성자가 호출되어야 한다.
조상 클래스의 생성자를 호출하는 것은 상속 관계를 계속해서 올라가면 가장 최상위 클래스인 Object 클래스의 생성자를 호출하게 된다.
따라서, Object 클래스를 제외한 모든 클래스의 생성자는 첫 줄에 반드시 자신의 다른 생성자 또는 조상 클래스의 생성자를 호출해야 한다.
그렇지 않으면, 컴파일러가 자동으로 첫 줄에 super();를 추가하게 된다.
class Parent{
int x,y;
Parent(int x, int y){
this.x = x;
this.y = y;
}
}
class Child extends Parent{
int z;
Child(int x, int y, int z){
this.x = x;
this.y = y;
this.z = z;
}
}
이 코드를 실행시키면 컴파일 에러가 발생한다. Child(int x, int y, int z){} 생성자의 첫 줄에 다른 생성자 또는 조상 클래스 생성자가 없기 때문에 컴파일러가 자동으로 super();를 추가할 것이다.
만약, 조상 클래스에 어떠한 생성자도 정의되어 있지 않았다면 컴파일러가 기본 생성자인 Parent(){}를 추가하여 컴파일 에러가 발생하지 않을 것이다.
하지만, Parent(int x, int y){} 생성자가 정의되어 있어 기본 생성자는 추가되어 있지 않다. 이러한 상황에서 super();가 자동으로 추가되면 호출할 생성자가 없기 때문에 컴파일 에러가 발생하는 것이다.
컴파일 에러를 없애려면 해결법은 2가지다.
- Parent(int x, int y){} 생성자를 제거한다
- super(int x, int y){} 생성자를 첫 줄에 추가한다
첫 번째 방법
class Parent{
int x,y;
}
class Child extends Parent{
int z;
Child(int x, int y, int z){
this.x = x;
this.y = y;
this.z = z;
}
}
두 번째 방법
class Parent{
int x,y;
Parent(int x, int y){
//super(); 자동 추가
this.x = x;
this.y = y;
}
}
class Child extends Parent{
int z;
Child(int x, int y, int z){
super(x,y);
this.z = z;
}
}
*조상 클래스의 멤버변수는 이처럼 조상의 생성자에 의해 초기화되도록 해야 한다
두 번째 방법으로 코드를 작성해서 실행하면 Child(int x, int y, int z){} 생성자가 가장 먼저 실행되고 그 후로 Parent(int x, int y){}, public Object(){} 순서대로 실행된다.
이처럼 어떤 클래스의 인스턴스를 생성하면 상속관계의 최고 조상인 Object 클래스까지 모든 조상 클래스의 생성자가 순서대로 호출되는 것을 알 수 있다.