티스토리 뷰

Java & Kotlin

[Java] 메소드 오버라이딩/ 메소드 오버로딩을 통한 상속 다형성에 대한 이해와 Self 참조

망나니개발자 2021. 10. 15. 14:23
반응형

아래의 내용은 오브젝트(Object)를 읽으면서 정리한 내용입니다. Java에 한정되는 이야기이므로 다른 언어에는 적용되지 않을 수 있습니다.

 

 

1. 상속을 이용한 다형성과 메소드 오버라이딩/ 메소드 오버로딩


[ Self 참조(Self Reference) ]

Java에서 객체가 메세지를 수신하면 컴파일러는 self 참조(Self Reference)를 임시로 자동 생성하여 메세지를 수신한 객체를 가리키도록 설정하고 상속 계층의 역방향으로 동적 메소드 탐색을 진행하는데, 이 과정을 순서대로 정리하면 다음과 같다.

  1. 컴파일러가 메세지를 수신한 객체를 가리키는 self 참조를 임시로 생성한다.
  2. 클래스에서 메세지를 처리할 메소드를 탐색한다.
  3. 메세지를 처리할 수 없으면 부모 계층으로 넘어가 메소드 탐색을 진행한다.
  4. 3~4의 과정을 반복하여 해당 메소드를 찾으면 self 참조를 소멸하고 메소드를 실행한다.

 

즉, self가 가리키는 객체의 클래스부터 시작하여 상속 계층의 역방향으로 동적 메소드 탐색을 진행하는 것이다. 물론 정적 언어인 Java의 경우에는 해당 메소드가 없으면 실행(런타임) 이전에 컴파일에러가 발생한다.

예를 들어 다음과 같은 Animal-Dog 상속 계층의 클래스가 있다고 하자.

public class Animal {

	public void bark() {
		System.out.println("동물이 짖는다.");
	}

	public void sleep() {
		System.out.println("동물이 잔다.");
	}

	public void barkAndSleep() {
		bark();
		sleep();
	}

}

public class Dog extends Animal {

	public void bark(final int times) {
		System.out.println("강아지가 " + times + "번 짖는다.");
	}

	public void sleep() {
		System.out.println("강아지가 잔다.");
	}

}

 

 

그러면 다음과 같이 객체를 생성하면 객체와 클래스 정보들이 메모리에 할당되는데, 클래스 정보 안에는 클래스 안에 구현된 전체 메소드 목록이 포함되어 있다. 객체들은 각각 메모리에 할당되지만 클래스 정보들은 1개만 할당된다. 만약 최상위 클래스인 Object에 이르러서도 적절한 메소드를 찾지 못하면 에러를 발생하게 되는데, 정적 타입 언어인 Java의 경우에는 컴파일 에러가 발생한다.

Animal animal = new Dog();

// animal.methodNotFound(); 컴파일 에러

animal.bark();
// 동물이 짖는다.

 

 

위에 객체가 할당되어 메소드가 탐색되는 것을 다음과 같이 표현할 수 있다.

 

메소드 탐색은 자식 클래스에서부터 부모 클래스의 방향으로 진행된다. 따라서 항상 자식 클래스의 메소드가 부모 클래스의 메소드보다 먼저 탐색되기 때문에 자식 클래스에 선언된 메소드가 부모 클래스의 메소드보다 더 높은 우선순위를 갖게 된다.

 

여기서 중요한 점은 실행되는 메소드가 컴파일 시점에 정해지지 않고 런타임 시점에 self 참조를 이용해서 동적으로 결정된다는 것이다. 그래서 런타임에 실제로 메세지를 수신한 객체의 타입을 추적해야 하며, self 참조가 이를 도와준다. 이에 대해 자세히 이해하기 위해 메소드 오버라이딩과 메소드 오버로딩을 가지고 이해해보도록 하자.

 

 

 

[ 메소드 오버라이딩(Method Overriding) ]

메소드 오버라이딩은 자식 클래스가 부모 클래스에 존재하는 메소드와 동일한 파라미터를 갖는 메소드를 재정의하여 부모 클래스의 메소드를 감추는 현상이다.

메소드가 오버라이딩된 경우에 메소드 탐색 과정을 따라가보도록 하자. 자식 클래스(Dog)에서 부모 클래스(Animal)에 오버라이딩된 메소드 bark()의 경우 다음과 같은 흐름으로 메소드가 탐색된다.

Animal animal = new Dog();

animal.sleep();
// 강아지가 잔다.

 

 

  1. Dog 객체에 대한 Self 참조 생성
  2. Dog 클래스에서 sleep() 메소드 탐색
  3. Dog 클래스의 sleep() 호출 후 종료

 

 

만약 생성된 객체가 Animal 객체였다면 Self 참조에 Animal 객체가 할당되므로, Animal 클래스부터 메세지 탐색을 진행할 것이다. 하지만 현재 생성된 객체는 Dog 객체이므로 Dog 클래스부터 메세지 탐색을 진행한다.

 

 

 

[ 메소드 오버로딩(Method Overloading) ]

메소드 오버로딩은 이름은 동일하지만 파라미터가 다르도록 메소드를 재정의하는 것이다. 하지만 여기서 많은 사람들이 놓치는 것은 메소드 오버로딩이 하나의 클래스 안에서 뿐만 아니라 상속 계층에서도 적용된다는 것이다. (C++에서는 상속 계층 사이의 메소드 오버로딩이 지원되지 않는다.)

이번에는 자식에서 메소드를 오버로딩한 경우를 살펴보도록 하자.

Dog 클래스를 보면 부모의 bark() 메소드를 오버라이딩한 bark(int times) 메소드가 있음을 확인할 수 있다. 먼저 Dog 클래스에서 bark() 메세지를 오버로딩한 bark(3) 메세지를 전송하는 경우를 살펴보도록 하자.

Dog dog = new Dog();
dog.bark(3);

// 강아지가 3번 짖는다.

 

  1. Dog 객체에 대한 Self 참조 생성
  2. Dog 클래스에서 bark(int minute) 메소드 탐색
  3. Dog 클래스의 bark(int minute) 호출 후 종료

 

 

동적 메소드 탐색은 메세지를 수신한 객체의 클래스인 Dog에서 시작된다. 해당 메소드는 Dog 클래스에 존재하므로 Animal 클래스까지 탐색을 진행하지 않고 종료된다.

 

이번에는 부모의 메소드를 호출하는 경우를 살펴보도록 하자. Dog 클래스에서 bark() 메세지를 전송하는 경우이다.

Dog dog = new Dog();

dog.bark();
// 동물이 짖는다.

 

 

  1. Dog 객체에 대한 Self 참조 생성
  2. Dog 클래스에서 bark() 메소드 탐색
  3. Animal 클래스에서 bark() 메소드 탐색
  4. Animal 클래스의 bark() 호출 후 종료

 

앞의 경우와 동일하게 동적 메소드 탐색은 메세지를 수신한 객체의 클래스인 Dog에서 시작된다. 하지만 이번에는 Dog 클래스 안에 응답가능한 메소드가 없으므로 부모 클래스인 Animal에서 메소드를 찾게 된다. Animal 클래스에서는 해당 메세지를 이해할 수 있으므로 해당 메소드를 실행하고 탐색이 종료된다.

 

 

 

[ 셀프 전송 (Self Send) ]

위에서 살펴본 예시들을 통해 메세지를 수신한 객체가 무엇이냐에 따라 메소드 탐색을 위한 문맥이 동적으로 바뀌는 것을 알 수 있었다. 그리고 이 동적인 문맥을 결정하는 것은 메세지를 수신한 객체를 가리키는 self 참조이다.

하지만 self 참조가 동적으로 메소드를 탐색하는 객체를 결정하는 것은 어떤 메소드가 실행될지 예측하기 어렵게 만드는데, 대표적인 경우가 자신에게 다시 메세지를 전송하는 self 전송(Self Send)이다.

 

self 전송에 대해 이해하기 위해 다음의 코드를 살펴보도록 하자. barkAndSleep 메소드에서는 bark() 메소드와 sleep() 메소드를 호출하고 있다. (엄밀히 말하면 객체에게 메세지를 전송하고 있는 것이다.)

Animal animal = new Dog();

animal.barkAndSleep();
// 동물이 짖는다.
// 강아지가 잔다.

 

 

  1. Dog 객체에 대한 Self 참조 생성
  2. Dog 클래스에서 bark() 메소드 탐색
  3. Animal 클래스에서 bark() 메소드 탐색
  4. Animal 클래스의 bark() 호출
  5. Dog 클래스에서 sleep() 메소드 탐색
  6. Dog 클래스의 sleep() 호출 후 종류

 

 

 

위의 과정을 천천히 살펴보도록 하자.

우리는 위에서 살펴봤듯이 메소드 탐색이 self 참조부터 시작됨을 알고 있다. 현재의 self 참조는 메세지를 처음 수신한 Dog 객체이다. bark() 메소드는 Dog 클래스에 없으므로 Animal 클래스에서 탐색을 진행하여 bark() 메소드를 호출해야 한다. 그리고 sleep() 메세지를 전송해야 하는데, 앞서 설명한대로 메세지를 받는 대상은 Self 참조이므로 Dog 객체이다. 그러므로 bark()가 호출된 Animal 클래스가 아닌 Dog 클래스부터 다시 메소드 탐색이 시작되고, 해당 메소드를 실행한 후에 메소드 탐색이 종료된다.

 

여기서 주목해야 하는 것은 다형성이 self 참조가 가리키는 현재 객체에게 메세지를 전달하는 특성을 기반으로 한다는 것이다. 동일한 타입의 객체 참조에게 동일한 메세지를 전송하더라도 self 참조가 가리키는 객체의 클래스가 무엇이냐에 따라 메소드 탐색을 위한 문맥이 달라진다.

자식 클래스의 입장에서 self 참조는 자식이고, 부모 클래스의 입장에서도 self 참조는 자식 인스턴스다. 왜냐하면 self 참조는 항상 메세제를 수신한 객체를 가리키기 때문이다. 메소드를 탐색할 때에도 자식 클래스의 인스턴스와 부모 클래스의 인스턴스는 메세지를 수신한 객체인 동일한 self 참조를 공유하는 것으로 봐도 무방하다.

 

 

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2024/11   »
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 30
글 보관함