Java에서의 묵시적, 명시적 형변환과 다형성
프로그램을 작성하다 보면 같은 타입뿐만 아니라 서로 다른 타입 간의 연산을 수행해야 할 때도 있다. 이런 경우 연산을 수행하기 전에 타입을 일치시켜야 하는데, 다른 타입으로 변환하는 작업을 형변환이라고 한다. 자바에서는 (타입)피연산자
와 같이 형변환하고자 하는 변수나 리터럴의 앞에 변환하고자 하는 타입을 괄호와 함께 붙여주면 된다. 여기에 사용되는 괄호()는 캐스트 연산자 또는 형변환 연산자라고 한다. 형변환 연산자는 그저 피연산자의 값을 읽어서 지정된 타입으로 형변환하고 그 결과를 반환할 뿐이므로 피연산자의 값은 변하지 않는다.
형변환은 크게 2가지로 나눌 수 있다. 자동적으로 변환되는 묵시적 형변환(implicit type casting)과 직접 변환하는 명시적 형변환(explicit type casting)이 있다. 묵시적 형변환의 경우 편의상 형변환을 생략할 수 있도록 하는 것인데 형변환이 이루어지지 않는 것은 아니고 컴파일러가 생략된 형변환을 자동적으로 추가해준다. 묵시적 형변환은 기존의 값을 최대한 보존하면서 값의 손실을 막기 위한 타입으로 자동 형변환하는 것이기 때문에 표현 범위가 좁은 타입에서 넓은 타입으로 이루어진다. 아래 그림에서 화살표 방향으로 변환은 묵시적으로 형변환되지만 그 반대 방향으로의 변환은 반드시 형변환 연산자를 사용하여 명시적으로 형변환이 돼야한다.
기본형 변수처럼 참조 변수도 형변환이 가능하다. 단 서로 상속관계에 있는 클래스 사이에서만 가능하기 때문에 자식 타입을 부모 타입으로, 부모 타입을 자식 타입으로의 형변환만 가능하다. 바로 윗 부모나 자식이 아닌 부모의 부모로도 형변환이 가능하기 때문에 모든 참조 변수는 모든 클래스의 부모인 Object 클래스 타입으로 형변환할 수 있다.
public class SmartPhone { ... }
public class IPhone extends SmartPhone { ... }
public class Galaxy extends SmartPhone { ... }
SmartPhone 클래스를 상속 받는 IPhone 클래스와 Galaxy 클래스가 있다. SmartPhone ↔ IPhone, SmartPhone ↔ Galaxy간 형변환은 가능하지만 IPhone ↔ Galaxy은 서로 상속 관계가 아니므로 형변환이 불가능하다. 참조 변수도 기본형 변수처럼 묵시적, 명시적 형변환이 가능한데 부모 타입으로 형변환 하는 경우 자동으로 형변환이 가능하지만 자식 타입으로의 형변환인 경우 명시적으로 형변환해 주어야 한다. 이 역시 데이터의 손실을 막기 위해서인데 부모 타입으로의 형변환을 생략할 수 있는 이유도 부모 타입의 멤버는 항상 자식 타입과 같거나 적기 때문이다.
IPhone iPhone = new IPhone();
SmartPhone smartPhone = iPhone; // 생략 가능 -> 묵시적 형변환
IPhone iPhone1 = (IPhone) smartPhone; // 생략 불가 -> 명시적 형변환
// 에러 발생 (Inconvertible types; cannot cast 'IPhone' to 'Galaxy')
// Galaxy galaxy = (Galaxy) iPhone;
instanceof 연산자는 참조 변수가 참조하고 있는 인스턴스의 실제 타입을 알아보기 위해 사용되고 참조 변수 instanceof 타입 클래스
형식으로 쓴다. 연산의 결과가 true이면 검사한 타입으로의 형변환이 가능하다는 것을 뜻한다.
사전적 의미의 다형성은 같은 종(種)의 생물이면서도 어떤 형태나 형질이 다양하게 나타나는 현상을 뜻한다. 객체지향 프로그래밍의 주요 원리 중의 하나인 다형성은 사전적 의미와 비슷하게 하나의 요소가 여러 가지 타입을 갖거나 다양하게 동작하는 것을 의미한다. 부모 클래스 타입의 참조 변수로 자식 클래스의 인스턴스를 참조할 수 있고 자식 클래스가 부모에게서 공통적으로 쓸 수 있는 멤버들을 공유받지만 자신만의 고유한 기능 또한 가질 수 있도록 해 다형성을 구현한다. 앞서 다뤘던 오버로딩과 오버라이딩도 다형성을 구현하는 방법에 포함된다.
SmartPhone 클래스를 상속 받는 IPhone 클래스와 Galaxy 클래스에는 각각 객체를 주고 받을 수 있는 airDrop과 quickShare 라는 메서드가 있다.
public class SmartPhone {
void powerOn() {}
void powerOff() {}
}
/////////////////////////////////////////////////////////////////
public class IPhone extends SmartPhone {
void airDrop(Object item) {}
}
/////////////////////////////////////////////////////////////////
public class Galaxy extends SmartPhone {
void quickShare(Object item) {}
}
IPhone과 Galaxy 클래스는 SmartPhone으로부터 상속 받은 powerOn 메서드와 자신만의 고유 기능인 airDrop, quickShare 메서드를 사용할 수 있다. 반면 자식 클래스인 IPhone 클래스를 참조하는 smartPhone 참조 변수는 powerOn 메서드는 쓸 수 있어도 airDrop 메서드는 쓰지 못한다. 자식 클래스의 멤버는 부모 클래스에 없기 때문이다.
IPhone iPhone = new IPhone();
Galaxy galaxy = new Galaxy();
SmartPhone smartPhone = new IPhone();
...
iPhone.powerOn();
iPhone.airDrop(photo);
galaxy.powerOn();
galaxy.quickShare(photo);
smartPhone.powerOn();
// 에러 -> Cannot resolve method 'airDrop' in 'SmartPhone'
// smartPhone.airDrop(photo);
// Galaxy galaxy2 = new SmartPhone(); // 에러
그렇다면 왜 부모 타입이 자식 타입을 참조할 수 있게 만들었을까? 다음과 같이 스마트폰 개통을 나타내는 ActivateSmartPhone 클래스와 개통하기위한 메서드 activateIPhone, activateGalaxy가 각각 있다. 만약 여기서 새로운 스마트폰을 개통하려고 하면 새로운 메서드를 작성해야 하고 계속 추가될 것이다.
public class ActivateSmartPhone {
void activateIPhone(IPhone iPhone, String model) {
System.out.println(iPhone.getClass().getName() + " " + model + " 개통 완료");
}
void activateGalaxy(Galaxy galaxy, String model) {
System.out.println(galaxy.getClass().getName() + " " + model + " 개통 완료");
}
public static void main(String[] args) {
ActivateSmartPhone activateSmartPhone = new ActivateSmartPhone();
activateSmartPhone.activateIPhone(new IPhone(), "13");
activateSmartPhone.activateGalaxy(new Galaxy(), "S22");
}
}
하지만 IPhone과 Galaxy의 부모인 SmartPhone을 개통하는 메서드를 작성하면 어느 회사의 스마트폰이든 상관없이 개통할 수 있다.
public class ActivateSmartPhone {
void activate(SmartPhone smartPhone, String model) {
System.out.println(smartPhone.getClass().getName() + " " + model + " 개통 완료");
}
public static void main(String[] args) {
ActivateSmartPhone activateSmartPhone = new ActivateSmartPhone();
activateSmartPhone.activate(new IPhone(), "13");
activateSmartPhone.activate(new Galaxy(), "S22");
}
}
다형성을 활용하면 이처럼 중복을 줄여 유지보수를 쉽게하고 명확한 코드를 작성할 수 있다.