상속

여기서, 상속(Inheritance)이란 말 그대로 '부모의 유산을 물려받다'를 의미하고 이는 '자식이 부모의 것을 가진다'라고 할 수 있습니다. 객체 지향 프로그래밍에서도 이와 비슷한 개념으로 사용되는데, 여기에서는 부모 클래스에 정의된 멤버(필드, 메서드 등)를 자식 클래스가 물려받는 것을 말합니다. 즉, 상속을 통해 기존에 있던 클래스(부모 클래스)를 이용하여 새로운 클래스를 만들 수 있습니다. 이는, 기존에 만든 것을 이용해서 만들어내기 때문에 적은 양의 코드로 새로운 클래스를 만들어 낼 수 있습니다. 

상속을 해주는 클래스는 부모 클래스(parent class)라고 하며 슈퍼 클래스(superclass), 기반 클래스(base class)라 부르기도 합니다. 상속을 받는 클래스를 자식 클래스(child class)라고 하며 서브 클래스(subclass), 확장 클래스(extended class), 파생/유도 클래스(derived class)라고 부르기도 합니다. 여기서는 일관성을 위해서 용어를 부모 클래스, 자식 클래스로 통일하도록 하겠습니다.

extends

만약, 자바에서 상속을 받게 해 주려면, 클래스 이름 뒤에 extends와 상속받고자 하는 클래스명을 입력해주시면 됩니다. 이렇게 하면 부모의 멤버를 상속받을 수 있으나, 생성자는 멤버가 아니므로 생성자는 상속되지 않습니다.

// A라는 클래스를 선언한다.
class A
{
	// ...
}

// B라는 클래스를 선언하고, 이 클래스는 A 클래스를 상속받는다.
class B extends A
{
	// ...
}

이해를 돕기 위해서 예제를 직접 보면서 자세히 설명하도록 하겠습니다.

class Animal {
	// protected, private, public은 접근 제어자에서 자세히 살펴볼 것입니다.
	protected String name;
	
	public Animal(String name) {
		this.name = name;
	}
	
	public void eat() {
		System.out.println(name + "(이)가 음식을 먹습니다.");
	}
	
	public void sleep() {
		System.out.println(name + "(이)가 잠을 잡니다.");
	}
	
	public String getName() {
		return name;
	}
	
	public void setName(String name) {
		this.name = name;
	}
}

class Dog extends Animal
{
	public Dog(String name) {
		super(name);
	}
	
	public void bark() {
		System.out.println(name + "(이)가 멍하고 짖습니다.");
	}
}

class Cat extends Animal
{
	public Cat(String name) {
		super(name);
	}
	
	public void meow() {
		System.out.println(name + "(이)가 야옹하고 웁니다.");
	}
}

class InheritanceExamples {
	public static void main(String[] args) {
		Dog dog = new Dog("벨라");
		Cat cat = new Cat("코코");
		
		dog.bark();
		dog.eat();
		dog.sleep();
		
		cat.meow();
		cat.eat();
		cat.meow();
		cat.sleep();
	}
}

코드가 좀 긴데, 이를 그림으로 살펴보면 다음과 같습니다.

자식 클래스인 Dog와 Cat은 부모 클래스의 Animal에게서 필드 name와 메서드 eat(), sleep(), getName(), setName()을 상속받습니다. 그래서 자식 클래스 내부에서 물려받은 필드와 메서드를 사용할 수 있습니다. 그리고 메인 메서드를 살펴보면 마치 자기 것인 것처럼 eat(), sleep() 메서드를 호출할 수 있습니다. 이를 그림으로 살펴보면 다음과 같습니다.

변수 섀도잉(Variable shadowing)

잠시 변수 섀도잉에 대해서 살펴보도록 하겠습니다. 위키피디아에서 정의를 빌려오면 변수 섀도잉은 특정 범위 내에서 선언된 변수가 외부 범위에서 선언된 변수와 같은 이름을 가질 때 발생합니다. 예를 들어서, 메서드 안에 필드와 이름이 같은 지역 변수(local variable)를 선언하면 외부에 있는 필드가 가려지는 것을 말합니다.

지역 변수(local variable)

지역 변수는 선언된 블록 내에서만 접근할 수 있는 변수를 말합니다. 전에도 말했지만, 블록은 여는 중괄호(})로 시작하여 닫는 중괄호(})로 끝납니다. 덧붙여서, for문의 초기식에서도 지역 변수를 선언할 수 있습니다. 지역 변수의 기본값은 없으므로 지역 변수를 선언한 다음 지역 변수를 사용하기 전에 반드시 초기화해야 합니다.

class Example {
	public Example()
    {
		int a; // 지역 변수 a
	}
	
	public void func()
    {
		int a; // 지역 변수 a
		
		for (int i = 0; i < 5; i++) { } // 지역 변수 i
	}
}

우리는 이미 변수 섀도잉이 일어나는 것을 본 적이 있습니다. 생성자 편에서는 자연스러운 것처럼 넘어갔지만, 다시 살펴보도록 하겠습니다.

class Car {
    private int speed;
    private int maxSpeed;
    
    public Car() {
    	speed = 0;
    	maxSpeed = 100;
    }
    
    public Car(int speed) {
    	this.speed = speed;
    }
    
    public Car(int speed, int maxSpeed) {
    	this.speed = speed;
    	this.maxSpeed = maxSpeed;
    }
}

코드에서 11행, 15~16행을 보시면 섀도잉을 확인할 수 있는데 이를 그림으로 확인하면 다음과 같습니다. 따라서 우리는 가려진 필드를 가리키기 위해 this 키워드를 사용했었습니다.

변수 하이딩(Variable hiding)

변수 하이딩은 동일한 이름을 가진 변수가 부모 클래스와 자식 클래스에 둘 다 존재할 경우 부모 클래스의 변수는 가려지는 것을 말합니다. 코드로 살펴보면 다음과 같습니다.

class Parent {
	int num = 10;
}

class Child extends Parent {
	int num = 100;
}

class InheritanceExamples {
	public static void main(String[] args) {
		Child child = new Child();
		
		System.out.println("child.num: " + child.num); // 100
	}
}

Child 클래스의 num 필드가 Parent 클래스의 num 필드를 가립니다. 따라서 13행에서 child.num의 값을 가져오려고 하면 Child 클래스의 num 필드의 값을 가져옵니다. 이를 그림으로 살펴보면 다음과 같습니다.

부모 클래스의 생성자 호출하기

다시 이전으로 돌아와서, 아래의 코드를 살펴보도록 하겠습니다. Dog 클래스의 생성자 내부에 super라는 키워드가 보이는데, 이 키워드는 무슨 역할을 하는 키워드일까요?

class Dog extends Animal
{
	public Dog(String name) {
		super(name);
	}
    
    // ...
}

우리가 생성자 편에서 봤던 this와 비슷하게, super는 상속 관계에서 부모 클래스 객체를 가리키는 참조입니다. 이 키워드를 통해서 부모의 필드나 생성자, 메서드에 접근할 수 있습니다. 이를 그림으로 살펴보면 다음과 같습니다.

흐름을 살펴보면, 'Dog dog = new Dog("벨라");'라는 문장을 만나면 Dog의 생성자로 이동합니다. 그 후 'super(name);'을 만나 부모의 생성자를 호출하여 name 필드가 "벨라"라는 값으로 초기화됩니다. 부모의 필드나 메서드에 접근할 때도 super 키워드를 이용하여 접근할 수 있습니다.

class Parent {
	public int a = 10;
}

class Child extends Parent {
	public int a = 20;
	
	public void display() {
		System.out.println("Child.a: " + a);
		System.out.println("Parent.a: " + super.a);
	}
}

class InheritanceExamples {
	public static void main(String[] args) {
		Child child = new Child();
		
		child.display();
	}
}

생성자에서 주의할 점

그리고 super 키워드를 사용하면서 주의하셔야 할 점들이 몇 가지 있습니다.

  • 자식 클래스의 생성자 안에서 반드시 부모의 생성자를 호출해야 합니다.
  • 반드시 자식 클래스의 생성자의 첫 줄에서 부모의 생성자를 호출해야 합니다.

우선 첫 번째부터 살펴보도록 하겠습니다. 자식 클래스의 생성자 안에서 반드시 부모의 생성자를 호출해야 한다고 되어있는데, 아래의 코드에서는 부모의 생성자를 호출하지 않아도 별 문제가 없는 것처럼 보입니다.

class Parent { }

class Child extends Parent {
	public Child() { }
}

하지만 위의 코드를 아래와 같이 수정하면 에러가 발생합니다.

class Parent {
	public Parent(int a) { }
}

class Child extends Parent {
	// 에러: 암시적 부모 생성자 Parent()가 정의되지 않았습니다. 다른 생성자를 명시적으로 호출해야 합니다.
	public Child() { }
}

왜 이런 에러가 발생하냐면, 우리가 자식 클래스의 생성자 내에서 부모의 생성자를 명시적으로 호출하지 않으면 컴파일러에서 super();를 생성자의 첫 줄에 자동으로 삽입하기 때문입니다. 하지만 부모 클래스엔 기본 생성자가 정의되지 않았으므로, 에러를 없애려면 아래와 같이 매개변수가 없는 생성자 Parent()를 정의하거나 super 키워드를 이용해 부모의 다른 생성자를 명시적으로 호출해야 합니다.

명시적(explicit) vs 암시적(implicit)

우선 implicit은 '(직접 표현되지 않더라도) 내포[포함]되는'이라는 의미를 가지고 있고, explicit은 '명백한, 분명한'이란 뜻을 가지고 있습니다. 여기에서 유추할 수 있는데, 명시적(explicit)은 프로그래머에 의해 수행된 것을 말하고, 암시적은 JVM이나 기타 도구에 의해 수행된 것을 말합니다. 예를 들어서, 우리가 명시적으로 기본 생성자를 정의하지 않아도 컴파일러에서 암시적으로 기본 생성자를 만들어줍니다.

class Parent {
	public Parent() { }
	
	public Parent(int a) { }
}

class Child extends Parent {
	public Child() { }
}

이번에는 두 번째를 살펴보겠습니다. 반드시 자식 클래스의 생성자의 첫 줄에서 부모의 생성자를 호출해야 한다고 나와있는데, 이를 무시하고 아래와 같이 적으면 에러가 발생합니다.

class Parent {
	public Parent(int a) { }
}

class Child extends Parent {
	public Child() { 
		System.out.println("생성자가 호출되었습니다.");
		// 에러: 생성자의 첫 줄에서 생성자를 호출해야 합니다.
		super(10);
	}
}

아래와 같이 수정해도 에러가 발생합니다. 부모의 생성자 Parent(int)가 호출되기 전에 func() 메서드를 호출하려고 했기 때문입니다.

class Parent {
	public Parent(int a) { }
}

class Child extends Parent {
	public Child() { 
		// 에러: 생성자를 명시적으로 호출하는 동안에는 인스턴스 메서드를 참조할 수 없습니다.
		super(func());
	}
	
	public int func() {
		System.out.println("생성자가 호출되었습니다.");
		return 0;
	}
}

따라서 반드시 자식 클래스의 생성자의 첫 줄에서 부모의 생성자를 호출해야 하며 호출하는 동안에는 객체의 메서드를 호출할 수 없음을 알 수 있습니다. 이렇게 하면 자식 객체가 부모 객체의 멤버에 접근하기 전에 부모 객체의 상태가 올바르게 설정되었음을 보장할 수 있습니다.

다중 상속

자바에서는 2개 이상의 클래스를 한꺼번에 상속받을 수 없습니다. 하지만 추후에 배울 인터페이스로는 다중 상속을 할 수 있는데, 아직 배우지 않았으므로 여기서는 설명하지 않습니다.

따라서 아래와 같이 코드를 작성하면 에러가 발생합니다.

class Mom { }

class Father { }

class Child extends Mom, Father { } // 문법 에러 발생

다이아몬드 문제(Diamond problem)

자바에서 클래스의 다중 상속을 지원하지 않는 이유 중 하나는 다이아몬드 문제가 발생하기 때문입니다. 자바를 설계한 사람들은 다중 상속이 이점보다 더 많은 문제를 가지고 온다는 사실을 알았으며, 이는 "단순하고, 객체 지향적이고, 친숙한 언어"와는 거리가 멀었기 때문에 다중 상속을 제외하기로 결심했습니다. 이런 다중 상속 문제는 자바뿐만 아니라 다른 많은 객체 지향 프로그래밍 언어에서 문제가 되고 있습니다.

이 문제는 죽음의 다이아몬드(Deadly Diamond of Death) 문제라고 불리기도 하며, 클래스 상속 다이어그램의 모양 때문에 다이아몬드 문제라고 불립니다. 예를 들어서, 아래와 같이 B, C 클래스가 A 클래스를 상속받고, D 클래스가 B, C 클래스를 상속받았다고 가정을 해봅시다.

이를 코드로 살펴보면 아래와 같습니다. 아래 코드는 에러가 발생하므로 컴파일이 되지 않으니 보기만 해 주세요.

class A {
	public void display() {
    	System.out.println("A.display()가 호출되었습니다.");
    }
}

class B extends A { 
	public void display() {
		System.out.println("B.display()가 호출되었습니다.");
	}
}

class C extends A { 
	public void display() {
		System.out.println("C.display()가 호출되었습니다.");
	}
}

// 자바에서는 아래와 같은 다중 상속을 지원하지 않습니다.
class D extends B, C { }

class InheritanceExamples {
	public static void main(String[] args) {
		D d = new D();
		
		// 무엇이 호출될까?
		d.display();
	}
}

여기서 27행을 보면 display() 메서드를 호출하는 것을 볼 수 있습니다. 하지만 이러면 컴파일러는 상속받은 B의 display()를 호출해야 할지, 상속받은 C의 display()를 호출해야 할지 알 수 없습니다.

다형성(Polymorphism)

다형성이란 무엇일까요? 단어에서 알 수 있듯이 poly는 '많은(many)'을 의미하고, morph는 '형태(forms)'를 의미합니다. 즉, 다형성은 다양한 형태를 가질 수 있는 능력이라고 할 수 있습니다. 자바에서는 어떤 객체를 다른 타입의 객체처럼 다루는 능력으로, 부모 클래스 타입의 참조형 변수로 자식 클래스 타입의 객체를 참조할 수 있습니다.

이해를 돕기 위해서 아래의 예제를 한번 보도록 하겠습니다.

class Shape {
	protected double width;
	protected double height;
	
	public Shape(double width, double height) {
		this.width = width;
		this.height = height;
	}
	
	public double getWidth() {
		return width;
	}
	
	public double getHeight() {
		return height;
	}
	
	public double getArea() {
		return 0;
	}
}

class Rectangle extends Shape {
	public Rectangle(double width, double height) {
		super(width, height);
	}
	
	public double getArea() {
		return width * height;
	}
}

class Triangle extends Shape {
	public Triangle(double width, double height) {
		super(width, height);
	}
	
	public double getArea() {
		return width * height / 2;
	}
}

class InheritanceExamples {
	public static void main(String[] args) {
		Shape[] shapes = { new Rectangle(5, 10), new Triangle(4, 6), new Rectangle(3, 5) };
		
		for (int i = 0; i < shapes.length; i++) {
			System.out.println((i + 1) + "번째 도형의 넓이: " + shapes[i].getArea());
		}
	}
}

48행을 보면 어떤 모양의 객체든 상관없이 getArea()를 호출하여 올바른 넓이를 구할 수 있습니다. 다형성을 통해 면적을 구하라는 하나의 동작을 각기 다른 방식으로 수행할 수 있습니다. 우선 이 코드를 보시고 아래와 같은 궁금증들이 드실 수 있습니다.

  • 45행: Shape 클래스 타입의 참조형 변수가 어떻게 Rectangle 객체를 가리킬 수 있을까요? 위에서 부모 클래스 타입의 참조형 변수로 자식 클래스 타입의 객체를 참조할 수 있다고 말씀드렸습니다. 여기서 별다른 캐스팅 없이도 에러가 발생하지 않는 것은 업캐스팅과 다운캐스팅에 관련된 문제입니다.
  • 48행: Shape 클래스 타입의 참조형 변수를 통해서 shapes[i].getArea()를 호출하고 있음에도 불구하고 어떻게 Rectangle.getArea(), Triangle.getArea()를 호출할 수 있을까요? 이는 메서드 오버라이딩, 바인딩과 관련된 문제입니다.

이를 해결하기 위해서 먼저 업캐스팅과 다운캐스팅에 대해 알아보고 이어서 메서드 오버라이딩, 바인딩에 대해서 알아보도록 하겠습니다.

업캐스팅과 다운캐스팅

업캐스팅은 자식 타입을 부모 타입으로 캐스팅하는 것을 말합니다. 반대로 다운캐스팅은 부모 타입을 자식 타입으로 캐스팅하는 것을 말합니다. 덧붙여서, 업캐스팅과 다운캐스팅은 상속 관계에서 정의된 용어입니다.

업캐스팅(Upcasting)

우선 메인 메서드가 있는 클래스만 보겠습니다.

class InheritanceExamples {
	public static void main(String[] args) {
		Rectangle rect = new Rectangle(5, 10);
		Shape shape = rect; // Shape shape = (Shape)rect;와 동일함
		
		System.out.println("넓이: " + shape.getArea());
	}
}

코드에서 4행을 보면 Rectangle 타입을 Shape 타입으로 업캐스팅하는 것을 볼 수 있는데, 업캐스팅은 자동으로 이루어지기 때문에 명시적으로 캐스팅하지 않아도 됩니다.

다운캐스팅(Downcasting)

다운캐스팅은 반대로 부모 타입을 자식 타입으로 캐스팅하는 것을 말합니다. 다운캐스팅은 업캐스팅과 달리 자동으로 이루어지지 않기 때문에 아래와 같이 명시적으로 캐스팅해야 합니다.

class InheritanceExamples {
	public static void main(String[] args) {
		Shape shape = new Rectangle(5, 10); // 업캐스팅
		Rectangle rect = (Rectangle)shape; // 다운캐스팅
		
		System.out.println("넓이: " + shape.getArea());
	}
}

하지만 아래의 경우에는 ClassCastException 예외가 발생합니다. 아래와 같은 다운캐스팅은 허용되지 않으며, 위와 같이 부모 클래스 타입의 참조형 변수가 자식 객체를 참조하고 있는 경우에만 다운캐스팅이 가능합니다.

class InheritanceExamples {
	public static void main(String[] args) {
		Shape shape = new Shape(5, 10);
		Rectangle rect = (Rectangle)shape; // 다운캐스팅
	}
}

오버로딩과 오버라이딩

메서드 오버로딩(Method overloading)

메서드 오버로딩은 메서드 편에서 이미 살펴봤습니다. 여기서 이를 다시 언급하는 이유는 같은 클래스 내 뿐만 아니라 상속 관계에서도 메서드 오버로딩을 할 수 있기 때문입니다. 간단히만 살펴보도록 하겠습니다.

class Parent {
	public void func(int a, int b) {
		System.out.println("Parent.func(int, int)가 호출되었습니다.");
	}
}

class Child extends Parent {
	public void func(int a, int b, int c) {
		System.out.println("Child.func(int, int, int)가 호출되었습니다.");
	}
}

class InheritanceExamples {
	public static void main(String[] args) {
		Child child = new Child();
		
		child.func(1, 2); // Parent.func(int, int)
		child.func(1, 2, 3); // Child.func(int, int, int)
	}
}

위의 코드를 컴파일 후 실행하면 17~18행의 코드가 서로 다른 func() 메서드를 호출하고 있음을 확인할 수 있습니다.

메서드 오버라이딩(Method overriding)

메서드 오버라이딩은 상속 관계에서 부모 클래스에 이미 있는 메서드를 자식 클래스에서 다시 정의하는 것을 말합니다. 메서드 오버라이딩을 메서드 재정의라고 하기도 합니다. 이때, 자식 메서드의 반환형과 메서드 시그니처(메서드명, 매개변수의 개수와 타입)는 부모 메서드와 동일해야 합니다.

class Animal {
    public void makeSound() {
        System.out.println("동물이 울음소리를 냅니다.");
    }
}

class Cat extends Animal {
    public void makeSound() {
        System.out.println("고양이가 울음소리를 냅니다.");
    }
}

public class InheritanceExamples {
    public static void main(String[] args) {
        Cat cat = new Cat();
        cat.makeSound();
    }
}

결과를 출력하면 "고양이가 울음소리를 냅니다."가 출력됩니다. 이 예제에서는 부모 클래스의 메서드를 무시하고 자식 클래스의 메서드를 우선하는 것을 볼 수 있습니다. 어느 메서드가 우선되는지는 동적 바인딩에서 이어서 살펴보도록 하겠습니다.

공변 반환(Covariant return)

그 전에 공변 반환이 무엇인지 알아보도록 합시다. 자바에서는 JDK 1.5부터 공변 반환을 지원하고 있는데, 원래는 반환형이 서로 일치해야 하지만 공변 반환을 통해 메서드를 오버라이딩 할 때 자식 클래스의 메서드 반환형이 부모 클래스의 메서드 반환형의 하위 타입이 될 수 있습니다.

class A {
    public A get() {
        return this;
    }
}

class B extends A {
    public B get() {
        return this;
    }
}

이해를 돕기 위해서 이를 그림으로 살펴보면 다음과 같습니다.

정적 바인딩과 동적 바인딩

정적 바인딩과 동적 바인딩을 살펴보기 전에 바인딩(binding)이 무엇인지 알아보도록 하겠습니다. 바인딩은 메서드 정의와 메서드 호출 사이의 연결을 말합니다. 아래 그림에서 확인해보도록 하겠습니다.

예를 들면, 위의 그림에서는 a.doSomething() 메서드 호출이 doSomething() 메서드 정의에 바인딩 되고, a.doStuff() 메서드 호출은 doStuff() 메서드 정의에 바인딩 된다고 말할 수 있습니다.

정적 바인딩(Static binding)

정적 바인딩을 컴파일 타임 다형성(compile-time polymorphism)이라고 부르기도 합니다. 정적 바인딩은 컴파일 중에 바인딩이 일어나며, 프로그램이 실행되기 전에 바인딩이 일어나기 때문에 초기 바인딩(early binding)이라고 부르기도 합니다. 정적 바인딩의 예는 메서드 오버로딩이 있습니다.

class Animal { }

class Cat extends Animal { }

class InheritanceExamples {
	public static void main(String[] args) {
		Animal animal1 = new Animal();
		makeSound(animal1);
		
		Animal animal2 = new Cat();
		makeSound(animal2);
		
		Cat cat = new Cat();
		makeSound(cat);
	}
	
	public static void makeSound(Animal animal) {
		System.out.println("동물이 울음소리를 냅니다.");
	}
	
	public static void makeSound(Cat cat) {
		System.out.println("고양이가 울음소리를 냅니다.");
	}
}

위의 코드에서 10행을 보시면 Animal 타입의 참조형 변수 animal2가 Cat 객체를 가리킵니다. 그 아래에는 makeSound() 메서드를 호출하며 animal2를 넘기고 있습니다. 여기서 컴파일러는 메서드 정의와 메서드 호출을 서로 연결하기 위해서 참조형 변수 animal2의 타입을 확인합니다. 이때, 실제 animal2이 가리키는 객체의 타입은 고려하지 않습니다. 그 후 해당 타입에 대한 메서드 정의가 있는지 확인하고 메서드 호출을 메서드 정의에 바인딩시킵니다. 따라서 makeSound(Animal) 메서드를 호출하므로 “동물이 울음소리를 냅니다.”라는 문자열을 출력합니다. 이를 그림으로 살펴보면 다음과 같습니다.

동적 바인딩(Dynamic binding)

동적 바인딩을 런타임 다형성(runtime polymorphism)이라고 부르기도 합니다. 동적 바인딩은 프로그램이 실행되는 동안 바인딩이 일어나며, 이 때문에 후기 바인딩(late binding)이라고 부르기도 합니다. 동적 바인딩의 예는 메서드 오버라이딩이 있습니다.

class Animal {
	public void makeSound() {
		System.out.println("동물이 울음소리를 냅니다.");
	}
}

class Cat extends Animal { 
	public void makeSound() {
		System.out.println("고양이가 울음소리를 냅니다.");
	}
}

class InheritanceExamples {
	public static void main(String[] args) {
		Animal animal1 = new Animal();
		animal1.makeSound();
		
		Animal animal2 = new Cat();
		animal2.makeSound();
		
		Cat cat = new Cat();
		cat.makeSound();
	}
}

위의 코드에서 19행을 보시면 Animal 타입의 참조형 변수 animal2를 통해 makeSound() 메서드를 호출하고 있습니다. 이때, 프로그램이 실행되는 동안 참조형 변수의 타입이 아니라 참조형 변수가 가리키고 있는 객체의 타입이 바인딩에 사용됩니다. 따라서 메서드 호출은 Animal.makeSound()가 아니라 Cat.makeSound() 메서드 정의에 바인딩되어 Cat.makeSound()가 호출되는 것입니다. 이를 그림으로 살펴보면 다음과 같습니다.

다시 살펴보기

이제 처음 코드로 돌아와서 다시 살펴보도록 하겠습니다. 캐스팅을 따로 해주지 않았음에도 불구하고 아래의 코드에서 에러가 발생하지 않았던 이유는 뭘까요?

Shape[] shapes = { new Rectangle(5, 10), new Triangle(4, 6), new Rectangle(3, 5) };

사실 우리가 명시적으로 캐스팅을 하지 않아도 업캐스팅이 자동으로 이루어지기 때문입니다. 이해를 돕기 위해서 위의 코드를 따로 분리하고 보면 아래와 같을 것입니다.

Shape[] shapes = new Shape[3];
shapes[0] = (Shape)new Rectangle(5, 10); // 업캐스팅
shapes[1] = (Shape)new Triangle(4, 6); // 업캐스팅
shapes[2] = (Shape)new Rectangle(3, 5); // 업캐스팅

그러면 Shape 클래스 타입의 참조형 변수를 통해서 shapes[i].getArea()를 호출하고 있음에도 불구하고 어떻게 Rectangle.getArea(), Triangle.getArea()를 호출할 수 있을까요?

for (int i = 0; i < shapes.length; i++) {
    System.out.println((i + 1) + "번째 도형의 넓이: " + shapes[i].getArea());
}

참조형 변수가 가리키고 있는 객체의 타입을 바인딩에 사용하는 동적 바인딩을 통해서 shapes[0], shapes[1], shapes[2]가 각각 가리키고 있는 객체의 타입에 따라 서로 다른 메서드가 호출된 것입니다. 여기까지 이해가 되시나요?

instanceof

instanceof 연산자는 객체(인스턴스)를 지정한 타입과 비교하는 이항 연산자입니다. 타입 비교 연산자라고 부르기도 하며, 객체가 클래스의 인스턴스인지, 자식 클래스의 인스턴스인지, 특정 인터페이스를 구현하는 클래스의 인스턴스인지 확인할 때 사용할 수 있습니다. 여부에 따라서 true나 false를 반환합니다. instanceof 연산자는 다음과 같이 사용합니다.

참조형변수 instanceof 참조타입

우선 아래와 같이 클래스가 있다고 해보겠습니다.

class A { }

class B extends A { }

class C extends A { }

그러면 아래 코드의 출력 결과를 살펴보도록 하겠습니다. 참조형 변수 a가 가리키고 있는 객체는 A 클래스의 인스턴스이기 때문에 3행에서는 true를 반환합니다. 하지만 B, C 클래스의 인스턴스는 아니기 때문에 false를 반환합니다.

A a = new A();

System.out.println(a instanceof A); // true
System.out.println(a instanceof B); // false
System.out.println(a instanceof C); // false

이어서 아래 코드의 출력 결과를 살펴보도록 하겠습니다. 참조형 변수 b가 가리키고 있는 객체는 B 클래스의 인스턴스이지만 부모 클래스인 A 클래스의 인스턴스이기도 하므로 3, 4행에서는 true를 반환합니다. 하지만 C 클래스의 인스턴스는 아니기 때문에 false를 반환합니다.

A b = new B();

System.out.println(b instanceof A); // true
System.out.println(b instanceof B); // true
System.out.println(b instanceof C); // false

좀 더 간단히 정리하면 'A instanceof B' 일때 A를 B로 캐스팅 할 수 있으면 연산의 결과는 true가 나온다고 할 수 있습니다.

class Parent { }

class Child extends Parent { }

class InheritanceExamples {
	public static void main(String[] args) {
		Child child = new Child();
		Parent parent = (Parent)child; // 캐스팅 가능
		System.out.println(child instanceof Parent); // true
	}
}

덧붙여서, 참조형 변수에 null이 오면 항상 false가 됩니다.

class A { }

class InheritanceExamples {
	public static void main(String[] args) {
		A a = null;
		
		if (a instanceof A) {
			System.out.println("이 문장은 출력되지 않습니다.");
		}
	}
}

'프로그래밍 관련 > 자바' 카테고리의 다른 글

20편. 패키지(Package)  (10) 2012.08.21
19편. 제어자(Modifiers)  (15) 2012.08.19
17편. 배열과 메서드, 다차원 배열  (11) 2012.08.13
16편. 배열(Array)  (19) 2012.08.12
15편. 생성자(Constructor)  (14) 2012.08.11