Live Study 6주차 - 상속


자바 상속의 특징

자식 클래스는 부모 클래스의 상태와 기능을 사용 가능

1
2
3
4
public class Hyundai extends Genesis {
Sedan sonata = new Sedan("소나타", 24_000_000);
Suv tucson = new Suv("투싼", 25_000_000);
}
1
2
3
public class Genesis {
Sedan g70 = new Sedan("Genesis G70", 48_500_000);
}
1
2
3
4
5
6
public class Driver {
public static void main(String[] args){
Hyundai hyundai = new Hyundai();
hyundai.g70.drive();
}
}
1
당신이 선택한 차종은 g70이며, 가격은 48500000원입니다.

Genesis 클래스와 Genesis 클래스를 상속(extends)받는 Hyundai 클래스이다. 그리고 이를 Driver 클래스에서 인스턴스를 생성해서 사용하는 코드이다.

부모클래스로부터 상속받는 자식클래스에서 부모 클래스의 상태와 기능을 확장하여 할 수 있다는 점에서 상속을 할 때 사용하는 키워드가 extends 이다.

위의 코드에서 SedanSuv 클래스 코드는 굳이 첨부하지 않았다.

main 메서드가 있는 Driver 클래스를 보면, Hyundai 객체의 인스턴스를 생성했는데, Genesisg70를 호출한걸 확인할 수 있다.

이게 가능한 이유는 Hyundai 클래스가 Genesis상속 받기 때문에 가능한 일이다.

반면 Genesis 객 인스턴스로 생성해서 sonata를 호출하면 어떨까?

1
2
3
4
5
6
public class Driver {
public static void main(String[] args){
Genesis genesis = new Genesis();
genesis.sonata.drive();
}
}

컴파일 에러가 발생한다. genesis 인스턴스에서는 sonata를 찾을 수 없기 때문이다.

두 객체의 관계는 다음과 같다.

Genesis 객체를 통해서는 g70만 접근할 수 있지만, Genesis를 상속받는 Hyundaisonata, tucson, g70 모두 접근이 가능하다. 상속은 부모에서 자식에게 가능하지만, 자식에서 부모로는 불가능하다.

다중상속

자바에서는 다중상속을 불허 한다. 이유는 아래의 코드를 통해 확인해보자.

1
2
3
4
5
6
7
8
public class ParentA {
protected String familyName = "Beckham";
protected String givenName = "David";

public void name(){
System.out.println(givenName+" "+familyName);
}
}
1
2
3
4
5
6
7
8
public class ParentB {
protected String familyName = "Adams";
protected String givenName = "Victoria";

public void name(){
System.out.println(givenName+" "+familyName);
}
}
1
2
3
4
5
6
7
8
public class Son extends ParentA, ParentB {
protected String givenName = "Brooklyn";

@Override
public void name() {
System.out.println(givenName+" "+super.familyName);;
}
}

위의 코드는 컴파일 에러가 발생한다. 이유는 Son 클래스에서 다중상속을 허용하지 않기 때문이다. 그렇다면, 자바는 왜 다중상속을 허용하지 않는걸까?

위 코드가 컴파일 에러가 발생하지 않는다고 가정해보자. Son 클래스의 name()에서 super.familyName은 무엇을 가져올 수 있을까?

자식 클래스에서 부모 클래스의 속성 또는 기능을 호출하려고 하는데, 위에서처럼 변수명은 같지만 할당된 리터럴이 다른 경우 JVM은 어느것을 호출해야할지 알 수 없게된다.

이런 경우를 다이아몬 문제(Diamond Problem) 이라고 한다. 따라서 자바에서는 다중상속을 지원하지 않는다.

그러나 인터페이스를 통해 다중상속을 비슷하게 구현할수 있긴 하다.

상속관계에서 메서드 호출

1
2
3
4
5
class A {
public void printClassName(){
System.out.println("My name is A");
}
}
1
2
3
4
5
6
7
8
class B extends A {
public void printClassName(){
System.out.println("My name is B");
}
public void hello(){
System.out.println("Hello from B");
}
}
1
2
3
4
5
class C extends B {
public void hello(){
System.out.println("Hello from C");
}
}
1
2
3
4
5
6
7
8
9
10
class StudyHalle {
public static void main(String[] args){
B b = new B();
C c = new C();

b.hello();
c.printClassName();
c.hello();
}
}

A -> B -> C 순서로 상속관계로 연결된 객체들이다. 상속관계에서 최하위 객체인 C 객체의 인스턴스를 생성해서 printClassName()hello() 를 호출해보았다.

1
2
3
Hello from B
My name is B
Hello from C

printClassName() 은 B 객체에서 오버라이딩을, hello()는 C 객체에서 오버라이딩을 한 메서드들이다.

호출하면, 상속관계에서 가장 가까운 객체의 메서드를 호출 하는 것을 알 수 있다.

업캐스팅/다운캐스팅

상속관계에서는 부모 클래스와 자식 클래스 간에 형변환(Casting)이 가능하다.

  • 업캐스팅
    • 자식 클래스의 객체가 부모 클래스로 형변환
  • 다운캐스팅
    • 부모 클래스의 객체가 자식 클래스로 형변환

상속관계에서 업캐스팅과 다운캐스팅이 어떻게 진행되는지 확인해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Person {
String name = "Sam";
}
class Student extends Person {
int age = 31;
}
public class InheritanceApp {
public static void main(String[] args) {
Student student = new Student();
Person person = new Person();

Student cast_student = (Student) person; // 다운 캐스팅
System.out.println(cast_student.name); // Sam
System.out.println(cast_student.age); // 31

System.out.println(student.age); // 31
Person cast_person = student; // 업 캐스팅
System.out.println(cast_person.age); // copile error
}
}

Student -> Person 의 상속관계이다. Person 타입의 인스턴스 객체 person을 자식 객체 타입인 Student 타입으로 다운 캐스팅하니까 모든 멤버 필드에 접근이 가능해졌다.

반면 자식 클래스 타입의 Student를 부모 객체 타입인 Person 타입으로 업 캐스팅하니까 기존엔 접근가능했던 자식 객체의 멤버 필드에 접근이 불가능해졌다. 형변환이 되었기 때문에 당연히 접근이 안되는 것이다.


super 키워드

super라는 키워드도 있는데, super() 라는 메서드도 존재한다. 각각의 차이는 다음과 같다.

  • super : 부모 클래스의 필드, 메서드 호출
  • super() : 부모 클래스의 생성자를 호출하는 메서드

코드를 통해 확인해보겠다. 생활코딩의 코드(출처)를 가져왔다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Cal {
int v1, v2;

Cal(int v1, int v2){
this.v1 = v1;
this.v2 = v2;
}
}
class Cal3 extends Cal {
Cal3(int v1, int v2) {
super(v1, v2);
}
}
public class InheritanceApp {
public static void main(String[] args) {
Cal c = new Cal(2,1);
Cal3 c3 = new Cal3(10, 20);
}
}

여기서 사용한 super() 를 인텔리J에서 확인해보면 다음과 같이 출력된다.

Cal 클래스를 상속받은 Cal3 클래스는 부모 클래스가 갖고있는 생성자를 반드시 호출 해야 한다.

이유는 부모 클래스인 Cal 클래스의 인스턴스를 생성하게 되면, 생성자가 우선적으로 호출되는데, Cal 클래스의 자식 클래스 역시 이 생성자를 호출해야지만 Cal 클래스를 온전히 계승한다고 볼 수 있기 때문이다.

이번엔 super 키워드를 사용하는 코드를 추가해봤다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Cal {
int v1, v2;
int v3 = 10;

Cal(int v1, int v2){
this.v1 = v1;
this.v2 = v2;
}
}
class Cal3 extends Cal {
int v3 = super.v3+super.v2;

Cal3(int v1, int v2) {
super(v1, v2);
}
}
public class InheritanceApp {
public static void main(String[] args) {
Cal c = new Cal(2,1);
Cal3 c3 = new Cal3(10, 20);

System.out.println(c3.v3);
}
}
1
30

Cal3 클래스에서 부모 클래스의 변수를 호출하는 변수를 선언했다. main 메서드에서 콘솔에 출력되는 수가 30인데, Cal3 클래스의 변수 v3이 부모 클래스의 v3(10)와 부모 클래스의 v2(생성자를 통해 들어온 20)의 합을 가져왔기 때문이다.


메소드 오버라이딩

오버라이딩은 부모 클래스의 메서드를 변형해서 사용하는 것을 의미한다. 부모로부터 상속받은 기능을 자신의 입맛대로 바꿔서 사용하는 것이다.

객체지향을 배울때 오버라이딩과 함께 배우는 개념으로 오버로딩이 있는데, 오버로딩은 부모 클래스의 메서드와 이름만 같을뿐 파라미터 갯수를 다르게 하여 다른 메서드로 사용하는걸 의미한다. 객체지향의 주요 개념중 하나인 다형성(Polymorphism) 이 사용된 개념이다.

1
2
3
4
5
6
7
8
9
10
class A {
public void hello(String host, String guest){
System.out.println("Hello, "+guest+" from "+host);
}
}
class B {
public void hello(String host, String guest1, String guest2){
System.out.println("Hello, "+guest1+", "+guest2+" from "+host);
}
}

오버라이딩과 오버로딩의 차이

그럼 오버라이딩과 오버로딩의 차이는 무엇일까?
오버로딩이 기능의 확장이라면, 오버라이딩은 기능의 재정의이라고 할 수 있다.

아래의 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Cal1 {
public int sum(int num1, int num2){
return num1+num2;
}
}
class Cal2 extends Cal1 {
public int minus(int num1, int num2){
return num1-num2;
}
}
class Cal3 extends Cal2 {
public int sum(int num1, int num2, int num3){
return num1+num2+num3;
}
public int minus(int num1, int num2){
return num1>=num2 ? num1-num2 : num2-num1;
}
}
public class Inheritance {
public static void main(String[] args) {
Cal2 cal2 = new Cal2();
Cal3 cal3 = new Cal3();

System.out.println(cal2.minus(10,20));
System.out.println(cal3.minus(10,20));
System.out.println(cal3.sum(1,2));
System.out.println(cal3.sum(1,2,3));
}
}
1
2
3
4
-10
10
3
6

여기서 오버라이딩 을 시도한 코드는 Cal3 클래스의 minus()이다. 부모 클래스의 메서드를 가져와서 기능을 변형시켰다.

Inheritance 클래스의 main 메서드에서 콘솔에 출력한 결과는 부모 클래스의 함수와 달랐다. 기능의 변경이 확인 된 것이다.

오버로딩 을 시도한 코드는 Cal3 클래스의 sum()이다. 오버라이딩은 이름만 같을뿐 다른 기능이므로 오버로딩을 시도하면 기능이 하나 더 생긴다. 따라서 Cal3 클래스의 인스턴스로는 sum()이라는 이름의 메서드 2개를 호출할 수 있다.

오버라이딩은 상속관계에서만 사용되지만, 오버로딩은 같은 클래스내에서도 사용할 수 있다는 특징이 있다.


다이나믹 메서드 디스패치 (Dynamic Method Dispatch)

메서드 디스패치 란, 런타임 시점(실행 시점)이 아닌 컴파일 시점에 어떤 메서드가 호출될지를 결정할 수 있는지 여부 를 정의한다.

메서드 디스패치는 두가지로 분류된다.

  • 정적 메서드 디스패치 (Static)
    • 컴파일시 결정되는 의존 관계
    • 컴파일 시점에 어떤 메서드가 호출될지를 결정하는 코드
  • 다이나믹 메서드 디스패치 (Dynamic)
    • 런타임시 결정되는 의존 관계
    • 컴파일 시점에는 어떤 메서드가 호출될지 알지 못하나 런타임 시점에 메서드가 결정되는 코드

추상 클래스

클래스가 설계도라면, 추상 클래스는 미완성 설계도이다. 추상 메서드를 작성할 일이 있을때, 추상 메서드를 작성하는 클래스에 abstract 라는 키워드를 작성하면, 추상 클래스가 된다.

단독으로 사용될 수 없다. 인스턴스로 생성할 수 없기 때문에 추상 클래스는 반드시 상속으로 구현해서 자식 클래스에서 인스턴스화해서 사용해야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
abstract class A {
public abstract int sum(int num1, int num2);
public void hello(){
System.out.println("Hello world");
}
}
class B extends A {
@Override
public int sum(int num1, int num2){
return num1+num2;
}
}
class C extends A {
@Override
public int sum(int num1, int num2, int num3){
return num1+num2+num3;
}
}
public class StudyHalle {
public static void main(String[] args){
B b = new B();
C c = new C();
System.out.println(b.sum(5,10));
System.out.println(c.sum(1,2,3));
}
}
1
2
15
6

추상 클래스 A를 상속받는 Class B, Class C를 통해서 sum()이라는 메서드를 서로 다르게 구현한 코드이다.

추상 클래스는 인스턴스로 생성할 수 없다. 추상 클래스를 상속받은 자식 클래스를 통해서만 인스턴스화 할 수 있다. 같은 이유로 추상 클래스는 final 키워드로 선언할 수 없다.

그럼 추상 클래스는 언제 사용해야할까? 추상 메서드를 선언하려면 메서드를 선언하는 클래스가 추상 클래스여야 한다. 고로 추상 메서드가 필요할때가 추상 클래스가 필요한 시점이 된다.

그럼 추상 메서드는 언제 필요할까? 추상 메서드란 선언부만 있고, 구현부는 없는 메서드 를 말한다. 인터페이스처럼 껍데기만 있는 것이다. 따라서 클래스간의 관계에서 공통 기능을 인터페이스 또는 추상클래스의 추상메서드로 분리할 수 있을 것이다.

여기까지 보면, 인터페이스와 다르지 않은것 같다. 인터페이스와 추상클래스는 어떤 차이가 있는걸까?

8주차 스터디를 하면서 인터페이스를 공부하면서 인터페이스와 추상클래스의 차이를 정리해보았다.
인터페이스와 추상클래스의 차이


final 키워드

final 키워드는 객체의 모든 대상에 적용이 가능하며, 불가변적인 특징을 갖게된다.

final 키워드 적용대상

대상 의미
클래스 변경될 수 없는 클래스, 확장(상속)이 불가능해진다.
메서드 변경될 수 없는 메서드, final로 지정된 메서드는 오버라이딩을 통해 재정의 할 수 없다.
멤버변수 변수 앞에 final이 붙으면, 값을 변경할 수 없는 상수가 된다.
지역변수

상수의 초기화

final 키워드를 사용해서 상수를 선언하더라도 생성자를 통해서 초기화를 할 수 있다. 만약 생성자를 통한 상수의 초기화가 불가능해지면, 이를 상속하는 모든 클래스에서 똑같은 상수 리터럴을 사용해야할 것이다.

출처 : 남궁성의 Java의 정석


Object 클래스

java.lang 패키지의 대표적인 클래스 중 하나면서, Java에서 사용하는 모든 클래스의 조상이다. 따라서 Object 클래스의 메서드는 모든 클래스에서 바로 사용할 수 있다.

예를 들면, String 클래스에서 equals()를 사용할 수 있는건, equals()가 Object 클래스의 메서드이고, Object 클래스를 String 클래스가 상속하기 때문에 가능하다.

Object 클래스의 메서드

Object 클래스의 메서드 설명
public final native Class getClass() 객체 자신의 클래스 정보를 담는 Class 인스턴스를 반환한다.
public native int hashCode() 객체 자신의 해쉬코드를 반환한다.
public boolean equals(Object obj) 객체 자신과 객체 obj가 같은 객체인지 알려준다.
protected native Object clone() throws CloneNotSupportedException 객체 자신의 복사본을 반환한다.
public String toString() 객체 자신의 정보를 문자열로 반환한다.
public final native void notify() 객체 자신을 사용하려고 기다리는 쓰레드를 하나만 깨운다.
public final native void notifyAll() 객체 자신을 사용하려고 기다리는 모든 쓰레드를 깨운다.
public final void wait() throws InterruptedException 다른 쓰레드가 notify() 또는 notifyAll()을 호출할때까지 현재 쓰레드를 잠시 대기하도록 한다.
public final native void wait(long timeoutMillis) throws InterruptedException 다른 쓰레드가 notify()나 notifyAll()을 호출할 때까지 현재 쓰레드를 무한히 또는 지정된 시간(timeout, nanos)동안 기다리게 한다.

더 이상 참조하지 않는 객체를 GC에 의해 호출하도록 하는 finalize()는 deprecate되었다.
그동안 finalize()는 아래의 이유로 사용하지 않는 것이 권고 되었다는데 결국 deprecate되었다.

  • 객체가 이용할 수 없게된 시점부터 finalize()가 실행되는 시점까지 긴 시간이 소요될 수 있는데, 이 시간은 GC 알고리즘을 추종함.
  • 반드시 실행된다는 보장이 없다.
  • 예외가 발생하더라도 예외가 무시된다.
  • 성능 저하가 발생한다.

출처 : Oracle Docs - Java11 Class Object