다형성
객체지향언어에서 제일 중요한 다형성이다. 다형성을 이해해야 앞으로 나올 객체지향 특징 중 하나인 추상화에 대해서, 그 추상화와 관련된 abstract와 interface에 대해서도 이해할 수 있다. 따라서 다형성을 꼭 이해하고 넘어가야 한다.
먼저 다형성의 정의를 원론적인 의미에서 살펴보자면 다형성은 여러 가지 형태를 가질 수 있는 능력이다. 그러나 이건 원론적인 의미이고, 실제로 다형성을 객체지향언어에 관점에서 보자면 다음과 같이 답해야 한다. 부모의 참조 타입 변수로 자식의 참조 타입 객체를 다루는 것. 여기서 그냥 넘어갈 수도 있지만 눈여겨 봐야 하는 것은 부모의 참조 타입 변수로 자식의 참조 타입 객체를 다루는 것이다. 즉 결론적으로 자식 타입의 객체를 다루는 것이다. 이 말에 초점을 두고 학습을 한다면 향후에 이해를 하는데에 있어서 큰 도움이 될 것이다. 기존에 우리는 코드를 작성하면서 다음과 같은 코드들을 봐왔다.
Tv t = new Tv();
SmartTv t2 = new SmartTv();
// SmartTv가 Tv를 상속받았다고 가정하자.
그러나 다형성을 이용하면 다음과 같이 코드를 작성할 수 있게된다.
Tv t = new Tv();
Tv t2 = new SmartTv();
이제 위에서 했던 말을 다시 살펴보면 부모의 참조 변수로 자식의 인스턴스 객체를 다루고 있는 것을 볼 수 있다. 이는 코드를 작성하면서 엄청난 유연함을 가져다주게 된다. 물론 장점만 있는 것은 아니다. 예를 들어서 다음과 같은 코드를 보자.
class Tv {
boolean power = false;
int channel = 0;
void power() {
power = !power;
}
void channelUp() {
channel++;
}
void channelDown() {
channel--;
}
}
class SmartTv extends Tv {
String text;
void caption() {
System.out.print(text);
}
}
다음과 같은 클래스가 있다고 가정을 했을 때, 우리는 상속파트에서 다음과 같은 것을 배웠다. 자식의 멤버는 부모의 멤버보다 절대 작을 수 없고 크거나 같다. 라는 사실을 알고 있다. 그러나 위에서 다형성을 이용해서 부모의 참조변수가 자식의 인스턴스를 다룬다고 봤을 때의 경우에 대해서 생각해보자.
부모에는 power, channel, power(), channelUp(), channelDown() 밖에 없는 것을 볼 수 있다. 따라서, 부모의 참조변수로 자식의 인스턴스를 가리키는 것이기 때문에 자식인 SmartTv에 있는 text와 caption() 메서드는 자연스럽게 사용 할 수 없게된다.
물론 t2는 온전히 0x200 번지에 있는 smartTv의 모든 멤버에 접근이 가능하지만 Tv에는 기본적으로 참조할 수 있는 변수에 text와 caption이 없었기 때문에 제외하고 참조할 수 있다. 하지만 SmartTv의 인스턴스에 접근해있는 것이기 때문에 만약 상속받은 메서드를 오버라이드 했다면 오버라이드 된 메서드에 접근을 할 수 있는 것이다. 이처럼 부모타입의 참조변수로 자식타입의 객체를 다루는 것을 다형성이라고 하고 이는 개발에 있어서 엄청난 유연성을 가지고 온다.
그러나 방금 얘기했듯이 다형성은 부모타입의 참조변수로 자식타입의 인스턴스를 다루는 것이다. 자식타입의 참조변수로 부모타입의 인스턴스는 다룰 수 없다. 그 이유는 간단한데, 자식타입은 부모가 가진 멤버보다 같거나 많을 수 밖에 없다. 그리고 대부분은 부모보다는 많다. 그런데 이 상황에서 자식타입의 변수가 부모 타입의 인스턴스를 가리키게 되면 부모타입의 인스턴스에는 자식타입의 변수가 가지고 있는 멤버를 가지고 있지 않기 때문에 오류가 발생하므로 아예 변환자체가 안된다. 그럼 이런 질문은 할 수가 있다.
부모타입도 자식 인스턴스에만 있고 부모타입에 없는 인스턴스에는 접근을 못하니까 똑같은거아닌가요?
나도 이런 생각을 했었고, 좀만 잘 생각해보자. 부모타입은 자식 인스턴스에 있는 부모타입에는 없는 곳에 접근 자체를 못한다. → 없기 때문에 접근을 할 수가 없다. 그런데 자식타입의 변수의 입장에서 부모 인스턴스를 다루었을 때를 생각해보자. 없는데도 접근할 수 있다. → 바로 이것이 문제이다. 부모는 자식타입의 인스턴스에만 있는 멤버에 접근자체가 안되지만, 자식은 부모 타입에는 없는데 접근할 수 있다는 것 이것이 초점이다. 컴퓨터는 메모리 구조상 해당 변수에 따라 어떤 메모리 주소에 값이 담겨있어야 CPU는 논리주소를 보내고 MMU가 이를 메모리에서 가져오는 형식으로 이루어지는데 없는 걸 어떻게 찾으란 말인가. 이런 이유에 따라서 불가능하다.
참조 변수의 형변환
우리는 다형성을 통해서 부모의 참조 변수로 자손의 인스턴스를 다룰 수 있다는 사실을 알게 되었다. 즉 이러한 이유로 인해서 부모 타입의 참조변수는 자손의 인스턴스와 형변환을 할 수 있는데, 우리는 어떤 타입이냐에 따라서 사용할 수 있는 멤버의 개수가 달라질 수 있다는 사실 또한 배웠다. 그럼 어떤 것은 형변환이 가능하고 어떤 것은 형변환이 불가능 한지에 대해서 예시를 한 번 보자.
public class Car {
String color;
int door;
void drive(){
System.out.println("운전한다.");
}
void stop() {
System.out.println("멈춘다.");
}
}
class FireEngine extends Car {
void water() {
System.out.println("물을 뿌린다.");
}
}
class Ambulance extends Car {
void heal() {
System.out.println("사람을 치료한다.");
}
}
위와 같은 상속관계에 대해서 상속 계층도를 살펴보자.
다음과 같은 상속 계층도를 가지고 있는 것을 알 수 있다. 이때 참조변수의 형변환은 부모 자식 관계에서만 가능하기 때문에, FireEngine에서 Ambulance로는 형 변환이 불가능 하다. 이제 참조변수를 형변환 하는 코드를 살펴보자.
FireEngine f = new FireEngine();
Car c = (Car)f // FireEngine() 객체를 부모타입인 Car로 형변환 하였다. 이때, (Car)는 생략이 가능하다.
자식에서 부모로 형변환을 하는 경우에는 형변환을 명시적으로 하지 않아도 된다.
FireEngine f2 = (FireEngine)c; // 자손으로 형변환을 하는 경우 명시적으로 형변환을 해줘야 한다.
Ambulance a = (Ambulance)c; // 오류가 발생
다형성을 공부할 때 해당 부분이 가장 이해가 안될 것이다.
어? 위에서 부모의 인스턴스는 자식 타입의 참조변수로 다룰 수 없다고 했잖아요! 지금 보면 부모의 인스턴스를 다룬거 아닌가요?
결론부터 말하자면 아니다. 위에서 얘기했지만 이 부분에 초점을 두어야 한다. 자 다시 한 번 자세히 살펴보자. 부모의 참조변수로 자식타입의 인스턴스를 다룰 수 있다.
- FireEngine 클래스의 인스턴스를 FireEngine타입의 f라는 참조변수에 담았다.
- 이 FireEngine 클래스의 인스턴스를 Car 타입으로 형변환을 하고 c라는 참조 변수에 담았다.
→ 여기가 중요포인트이다. 그럼 지금 Car 타입의 인스턴스는 무엇인가? 놀랄 수 있겠지만 FireEngine 인스턴스이다.
- 마지막으로 다시 FireEngine 클래스의 f2 라는 참조변수에 Car로 형변환 되어 있었던 c 참조변수를 FireEngine으로 FireEngine 인스턴스를 형변환 것이다.
과정을 다시 지금까지 읽어보면 FireEngine 인스턴스가 생긴 이후로 절~~대 Car라는 새로운 인스턴스가 생긴적이 없다. 우리는 쭉 FireEngine의 인스턴스만 다룬것이다. 따라서 위에서 얘기했지만, 형변환은 부모 자식간의 관계에서만 가능하기 때문에, FireEngine 인스턴스가 담겨있는 참조 변수 c를 Ambulace로 형변환을 하려고 하니 형제 관계에 있는 건 형변환이 불가능하기 때문에 오류가 발생하는 것이다. 다음 코드를 살펴보고 메모리가 어떻게 이동되는지 살펴보자.
// 위에 있던 Car, FireEngine, Ambulance가 public 클래스로 선언되어 있다고 가정하자.
class Test {
public static void main(String[] args) {
Car car = null;
FireEngine fe = new FireEngine();
FireEngine fe2 = null;
fe.water();
car = fe; // 형 변환 발생 -> car = (Car)fe;
car.water(); //오류 발생
fe2 = (FireEngine)car; // 형 변환 발생 부모 -> 자식이기 때문에 생략 불가
fe2.water();
}
}
참조변수 fe가 생성된 FireEngine 클래스의 인스턴스를 가리키고 있다. 그리고 fe는 water() 메서드를 호출하여 call stack에 water()가 담기고 빠져나가게 된다.
우리는 참조변수가 인스턴스의 주소를 담는다는 것을 알고 있다. 따라서, FireEngine 인스턴스 객체의 주소를 담고 있던 참조변수 fe를 car의 참조변수에 대입하게 되면서 기존에 car가 담고있던 주소 null이 FireEngine 인스턴스 객체의 주소은 0x100으로 바뀐 것을 볼 수 있다. 형변환이 되면서 자연스럽게 FireEngine의 참조변수 fe가 담고 있던 water() 메소드는 사용하지 못하게 된다. 그런데 이후 water()를 호출하려고 하는 모습을 볼 수 있다. 호출을 못할 뿐더러 컴파일러에서 오류가 발생한다.
이어서, 참조변수 car에 담겨있는 FireEngine 인스턴스의 주소를 FireEngine타입의 fe2 참조변수에 대입하게 된다. 자연스럽게 FireEngine 타입의 fe2 변수는 다시 water() 메소드의 존재를 알기 때문에 사용할 수 있게 변하게 되고 call stack에 water() 메소드가 담겼다가, return 되고 메소드가 종료된다.
위 그림을 보면 알겠지만 우리는 처음 생성한 FireEngine 인스턴스의 주소가 옮겨다니는 것을 확인할 수 있었다. 이렇듯 인스턴스 관점에서의 형변환에 초점을 두어야 한다. 부모타입의 참조변수로 형 변환 할때에는 명시적인 형변환을 표시안해도 되는데, 부모타입의 참조변수에서 자식타입의 참조변수로 형변환을 하게 될때에는 명시적으로 하는지에 대해서 느낌이 올 수 있다. 지금까지 봤듯이 자식타입의 참조변수에서 부모타입의 참조변수로 형변환을 하게 되면, 원래 자식 인스턴스에 있던 어떠한 멤버를 못쓸뿐이지 위험한 부분은 없다. 그러나 부모타입의 참조변수에서 자식타입의 참조변수로 형변환을 하는 순간, 사용하지 못했던 멤버를 사용할 수 있게 된다. 이와 같이 어떤 참조변수가 부모였고, 어떤 참조변수가 자식이었는지 확인할 수 있을 뿐더러, 새로운 멤버가 해당 참조변수에서 사용할 수 있다는 것을 명시하기도 한다.
결론
다형성과 형변환에 대한 결론을 정리해보자.
- 자손 타입의 참조 변수로 부모 타입의 인스턴스 객체를 다룰 수 없다.
- 부모와 자손타입은 형변환이 가능하다. 이때, 인스턴스 객체가 부모 타입이라면 자손타입으로 형변환은 불가능하다.
- 조상과 자손끼리의 형변환만 가능할 뿐 형제끼리의 형변환은 허가하지 않는다.
instanceof 연산자
instanceof 연산자는 참조변수의 형변환 가능여부를 알려준다. 가능하면 true를 반환해준다. 이렇게 말로 설명하는 것보다 예시를 한 번 확인하는게 더 도움이 될 것 같다.
void doWork(Car c){
if(c instanceof FireEngine) {
FireEngine fe = (FireEngine)c
fe.water();
}
}
위에 선언된 클래스들은 위에서 참조변수의 형변환에서 살펴본 코드에 작성된 클래스들과 같다. 다형성에 의해서 매개변수인 c에는 다형성에 의해서 new Car(), new FireEngine(), new Ambulance() 이렇게 3개의 객체가 모두 올 수 있다. 만약에 매개변수로 인스턴스 Car가 왔다고 하자. 인스턴스 Car은 FireEngine으로 형변환이 가능한가? 당연히 불가능하다. 자손의 참조변수로는 부모의 인스턴스를 다룰 수가 없다. 따라서 해당 코드에서는 false를 반환하게 된다. 테스트를 위해 다음과 같은 코드를 작성하였다.
public class main
{
public static void main(String[] args)
{
Rescue rescue = new Rescue();
Car c1 = new Car(); // 1
Car c2 = new FireEngine(); // 2
Car c3 = new Ambulance(); // 3
rescue.turnOffFire(c1); // false
rescue.turnOffFire(c2); // true, 실행
rescue.turnOffFire(c3); // false
}
}
class Rescue {
void turnOffFire(Car c){
System.out.println(c instanceof FireEngine);
if(c instanceof FireEngine){
FireEngine fe = (FireEngine)c;
fe.water();
}
}
}
class Car {
String color;
int door;
void drive(){
System.out.println("운전한다.");
}
void stop() {
System.out.println("멈춘다.");
}
}
class FireEngine extends Car {
void water() {
System.out.println("물을 뿌린다.");
}
}
class Ambulance extends Car {
void heal() {
System.out.println("사람을 치료한다.");
}
}
매개변수 인자로 들어갈 수 있는 모든 Car 클래스에 상속되어있거나 본인인 객체들을 생성해서 실행된 결과를 볼 수 있다.
- Car() 는 자식 참조변수가 부모의 인스턴스를 다룰 수 없기 때문에 당연히 false가 출력되고 실행되지 않는다.
- FireEngine() 은 부모 참조변수가 자식의 인스턴스를 다룰 수 있고, 해당 인스턴스는 또한 FireEngine 으로 형변환이 가능하기 때문에 형변환이 되고 실행이 되는 것을 확인할 수 있다.
- Ambulance()는 FireEngine 클래스와 형제관계에 있기 때문에 형변환이 불가능하다.
이러한 내용이 잘 이해가 되지 않으면, 앞에서 다형성이 어떻게 이루어지는지에 대해서 다시 한 번 확인해보고, 메모리 구조를 그려보고 궁금한 것이 있다면 직접 짜보는 것이 학습에 크게 도움이 될 것이다.
추가적으로 이런 의문이 들 수 있다. 왜 저렇게 타입을 변환해서 출력을 한거지? 라고 할 텐데, 형변환에 의해서 참조 변수가 달라지면서 사용가능한 멤버의 개수도 달라질 뿐더러 오버라이드 되어있으면 사용되는 메소드의 기능 또한(크게 변하진 않겠지만) 달라질 수 있기 때문이다.
매개변수의 다형성
하지만 다형성을 사용함으로써 우리는 유연하게 어떤 클래스가 들어와야 하는지 명시를 해줄 필요없이 사용할 수 있는 것을 볼 수 있다. 위와 같이 참조형 매개변수 선언 시 다형성으로 인해서 자신과 같은 타입 혹은 자손 타입의 인스턴스를 인자로 넘겨줄 수 있다는 장점이 있고, 이에 따라 다르게 동작하게 할 수 있다는 장점이 있다. 다음과 같은 예시를 살펴보자.
class Product {
int price;
int bonusPoint;
}
class Tv extends Product {
}
class Computer extends Product {
}
class Audio extends Product {
}
class Buyer {
int money = 1000;
int bonusPoint = 0;
void buy(Tv t){
money -= t.price;
bonusPoint += t.bonusPoint;
}
}
위 처럼 메소드의 매개변수를 Tv로 한정을 지으면 저 메서드에는 이제 Tv 자기 자신의 인스턴스 혹은 Tv의 자손타입만 매개변수에 들어 올 수 있게 된다. 하지만 세상에는 상품이 얼마나 많은가, 위에만 봐도 3개가 있는데, 컴퓨터와 오디오를 팔기 위한 메서드를 만들기 위해 동일한 메서드 2개를 오버로딩해서 매개변수 이름을 달리 해서 똑같은 코드를 반복해서 사용하면서 만들어주어야 한다. 다음과 같이 말이다.
void buy(Tv t){
money -= t.price;
bonusPoint += t.bonusPoint;
}
void buy(Computer c){
money -= c.price;
bonusPoint += c.bonusPoint;
}
void buy(Audio a){
money -= a.price;
bonusPoint += a.bonusPoint;
}
이러면 객체지향언어를 사용 할 이유가 없지 않은가? 객체지향언어의 장점은 코드의 재사용성과 간결함이라고 했는데 하나도 간결해보이지 않는다. 이러한 문제를 해결하기 위해 다형성과 참조변수의 형변환으로 이를 해결할 수 있다. 다음과 같은 코드를 확인해보자.
void buy(Product p){
money -= p.price;
bonusPoint += p.bonusPoint;
}
이제 다형성으로 인해서 buy 메서드의 매개변수로 Product 인스턴스와 그 자손 인스턴스들이 들어올 수 있게 되고 그 인스턴스들의 값에 따라서 다르게 동작할 것이다. 이제 객체지향언어의 장점이 눈에 보일 것 이다. 이러한 개념이 객체지향언어의 5원칙이 있는데 **리스코프 치환원칙**에 해당한다. 상위 타입의 객체를 하위 타입의 객체로 치환해도 즉, FireEngine fe = (FireEngine)c 다음과 같은 상황일 때에도 프로그램은 정상적으로 작동해야 한다는 원칙이다.
여러 종류의 객체를 배열로 다루기
다형성의 특징에 의해서 조상타입의 배열에 자손들의 객체를 담을 수 있다. 백문이 불여일견 코드를 한 번 살펴보도록 하자.
class Buyer {
int money = 1000;
int bonusPoint = 0;
void buy(Product p){
money -= p.price;
bonusPoint += p.bonusPoint;
}
}
위에 Buyer 클래스를 가지고 왔다. 이제 물건을 살 때마다 장바구니에 해당 물건을 추가하려고 한다. 이는 다음과 같이 표현할 수 있다.
class Buyer {
int money = 1000;
int bonusPoint = 0;
Product[] pArr = new Product[10]; // 해당 코드 영역이 포인트이다.
int index = 0;
void buy(Product p){
if(money - p.price < 0) return;
money -= p.price;
bonusPoint += p.bonusPoint;
pArr[index++] = p
}
void summary() {
for(int i = 0; i < pArr.length; i++){
if(pArr[i] == null) break;
System.out.println(pArr[i].price + "," + pArr[i].bonusPoint);
}
}
}
만약 위처럼 작성하지 않는다면 다음과 같이 배열을 구성해야 하는데, 엄청난 노가다이고 비효율적이다.
Tv[] tArr = new Tv[10];
Audio[] AuArr = new Audio[10];
Computer[] coArr = new Computer[10];
.
.
.
제품이 계속해서 늘어난다면 늘어난 제품에 대해서 계속 해서 배열을 생성해주어야 하는데, 이러한 문제를 다형성을 이용해서 부모 타입의 참조 배열에 본인 포함 자손 타입의 객체들을 저장할 수 있게 되었다. 이것디 다형성의 장점이다.
추상 클래스, 추상 메서드
이제 다형성에 대해서 이해했으면 추상화에 대해서 이해를 해야한다. 지금부터 굉장히 어렵기 때문에 이해하도록 노력해야 한다. 다형성을 이해하지 못했다면 뒤돌아가서 다형성을 확실하게 이해를 하고 넘어와야 한다. 이해하지 못한 상태로 이 파트를 보는건 도움이 안된다.
추상 클래스는 위에서 설명했듯이, 추상 메서드가 있는 클래스이다. 즉 미완성 설계도이다.
abstract class Player {
boolean pause;
int currentPos;
Player() {
pause = false;
currentPos = 0;
}
abstract void play(int pos);
abstract void stop();
void play(){
play(currentPos);
}
}
위와 같은 클래스를 추상 클래스라고 한다. 그럼 추상 클래스는 왜 사용하는가에 대해서 의문이 생길 수 있다. 결론부터 얘기하면 추상클래스는 각 구현체들 마다 구현 방식이 달라질 때 사용하기 위해, 즉 다른 클래스 작성에 도움을 주기 위해 사용하는 것이다. 그리고 특이한 점은 추상 클래스의 메서드 안에서 추상 메서드를 사용 할 수 있다는 것이다. 이것이 가능한 이유는 추상 클래스에 있는 메서드는 결국에 사용하기 위해서는 상속받은 다른 클래스가 해당 메서드를 무조건 구현할 것이기 때문에 사용이 가능한 것이다. 또한 추상 클래스라고 그래서 생성자를 사용할 수 없는 것도 아니다. 추상 클래스가 다른 클래스와 다른 점은 오직 추상 메서드를 사용한다는 점 말고는 차이점이 없다. 물론 다음과 같은 상황은 되지 않는다.
Player p = new Player(); // 오류 발생
추상 클래스는 앞에서 얘기했듯이, 미완성 설계도이다. 근데 완성되지 않은 설계도로 인스턴스를 생성하려고 하면 제대로 동작하지 않을 것이다. 따라서 컴파일 단계에서 오류를 발생시킨다.
이어서 다음과 같은 질문 또한 나올 수 있다.
class Player {
void play(int pos){}
void stop(){}
}
그러면 굳이 추상클래스라는 것을 사용하지 않고 위처럼 코드를 작성하고, 오버라이드 하면 되지 않나요? 물론 틀린 말은 아니다. 그러나 추상 메서드를 가진 클래스의 장점은 추상 클래스를 상속받았을 때 추상 메서드를 구현하지 않으면 컴파일 단계에서 오류를 발생시킨다. 즉, 이 부분은 꼭 구현해줘야해! 라고 알려주는 것이다. 만약 같이 협업을 하는 개발자가 추상 메서드인지 몰라서 구현을 하지 않았다고 가정해보자. 그러면 해당 개발자 입장에서는 이 메서드가 왜 동작을 하지 않지? 라고 알 수 밖에 없다. 왜냐하면 구현하라는 표시가 되어있지 않기 때문이다. 이렇듯 abstract 제어자는 개발을 하면서 해당 메서드는 꼭 구현을 해줘야 한다고 표시하기 위한 의사소통 수단도 된다. 위에 있는 추상 클래스를 사용하기 위해서는 다음과 같이 해야할 것이다.
class AudioPlayer extends Player {
void play(int pos) {
currentPos = pos;
System.out.println(pos + "에서 시작합니다.");
}
void stop() {
System.out.println("실행을 멈춥니다.");
}
}
위에 보면 AudioPlayer가 Player 추상 클래스에 있는 play 추상 메서드와 stop 추상 메서드를 구현한 것을 볼 수 있다. 그러나 다음과 같은 상황이 또 발생할 수 있다.
abstract class AudioPlayer extends Player {
void play(int pos) {
currentPos = pos;
System.out.println(pos + "에서 시작합니다.");
}
/* 구현 안함
void stop() {
System.out.println("실행을 멈춥니다.");
}
*/
}
위와 같이 추상 메서드로 선언되어있던 stop() 메서드를 구현해주지 않는다면 또 다시 AudioPlayer는 추상 클래스가 되어버린다. 이렇듯 추상 메서드는 꼭 구현을 해줘야하고, 물론 위처럼 다시 구현을 하지 않고 AudioPlayer를 새로 상속받아서 거기서 stop을 구현하면 해당 인스턴스를 생성하는 것이 가능하다.
그리고 추상 클래스의 가장 큰 장점은 다형성을 이용하는 것이다. 다음과 같은 예시를 보자.
abstract class Player {
abstract void play(int pos);
abstract void stop();
}
class AudioPlayer extends Player {
void play(int pos) {
System.out.println(pos + "에서 시작합니다.");
}
void stop() {
System.out.println("실행을 멈춥니다.");
}
}
public class Main {
public static void main(String[] args) {
// 다형성을 사용하지 않고 생성하는 방식
AudioPlayer player = new AudioPlayer();
player.play(30); // 30에서 시작합니다.
// 다형성을 사용해서 생성하는 방식
Player player2 = new AudioPlayer();
player2.play(30); // 30에서 시작합니다.
}
}
두 코드 모두 정상적으로 동작하는 것을 알 수 있다. 기존에 얘기했던 것과 같이 조상의 참조변수를 통해서 자식의 인스턴스 객체를 다룰 수 있다고 한 것과 동일하다. 이어서 AudioPlayer에 추가된 멤버가 없고 Player가 가지고 있던 멤버를 구현만 해주었기 때문에, 기능이 추가된 것 없이 AudioPlayer가 구현한 기능에 맞게 실행이 되는 것이다. 이렇듯 다형성을 이해하면 추상화도 쉽게 이해가 가능하다. 실제로도 위 처럼 사용하려는 실제 객체를 선언하는 형식은 적고, 다형성을 이용해서 최상위 부모를 가리키고 instanceof 를 사용해서 해당 클래스가 형변환이 가능한지 판단하여 사용하려는 기능에 맞게 사용한다.
추상 클래스의 작성
그럼 추상 클래스는 언제 작성하고, 어떻게 작성해야 될까? 방법은 두 가지이다. 첫번째는 클래스를 작성하다 보니 공통되는 메서드들이나 변수들이 있었고, 그때 바꾸는 방법 두번째는 처음 설계할 때부터 기능을 정확하게 체크해보고 그에 맞게 추상 클래스를 작성하는 방법 이렇게 두 가지 방법이 있다. 전자의 방법은 기획자나, DBA가 없을 때 많이 발생하는 상황이 될 것 이고, 후자의 방법은 정말 설계를 깔끔히 잘하고, 시니어가 있을 때 저런 방향으로 많이 흘러갈 것이다. 설계가 잘되어있는 애플리케이션은 변경과 유지보수가 줄어들 것이기 때문이다. 이어서 스타크래프트 라는 게임의 유닛을 두고 추상 클래스를 어떻게 작성하게 되는지 살펴보자.
class Marine {
int x, y
void move(int x, int y){}
void stop(){}
void stimpack(){}
}
class Tank {
int x, y
void move(int x, int y){}
void stop(){}
void changeMode(){}
}
class DropShip {
int x, y
void move(int x, int y){}
void stop(){}
void load(){}
void unload(){}
}
위와 같은 클래스들이 있다고 가정하자. 마린과 탱크는 지상유닛이고 드랍쉽은 공중유닛이다. 이러한 상황에서 좌표 x, y와 move메서드, stop 메서드가 겹치는 것을 볼 수 있다. 이와 같은 상황에 공중유닛과 지상유닛은 이동하는 방식이 다르기 때문에 move라는 메서드를 추상 메서드를 만들어서 각 인스턴스 별로 다르게 구현하도록 해야한다.
abstract class Unit {
int x, y;
abstract void move(int x, int y);
void stop(){
System.out.println("멈춤");
}
}
class Marine extends Unit {
void move(int x, int y){
System.out.println("[Marine]" + x + "," + y + "로 이동");
}
void stimpack(){}
}
class Tank extends Unit {
void move(int x, int y){
System.out.println("[Tank]" + x + "," + y + "로 이동");
}
void changeMode(){}
}
class DropShip extends Unit {
void move(int x, int y){
System.out.println("[DropShip]" + x + "," + y + "로 이동");
}
void load(){}
void unload(){}
}
다음과 같이 바꿀 수 있을 것이다. 이렇게 추상 클래스를 통해서 각각 다르게 구현해야 하는 클래스들은 다르게 구현하고 공통되는 부분은 그대로 사용할 수 있을 것이다. 추가로 해당 클래스들을 이용하기 위해서 이전에 배웠던 조상의 참조변수를 이용한 객체 배열에서도 사용이 가능한데 다음과 같은 예시를 살펴보자.
// 위에 있는 클래스들이 그대로 있다고 가정하자.
public class Main {
public static void main(String[] args){
Unit[] arr = new Unit[10];
arr[0] = new Marine();
arr[1] = new Tank();
arr[2] = new DropShip();
//Unit[] arr = new Unit[]{new Marine(), new Tank(), new DropShip()}; 도 가능
for(int i = 0; i < arr.length; i++){
if(arr[i] == null)
break;
arr[i].move(100, 200); // 각 객체의 인스턴스에 맞는 move 메서드가 실행 될 것이다.
}
}
}
다음과 같이 다형성을 활용하여 각 인스턴스를 다루기 쉽게 변경 할 수도 있다. 이렇게 추상화와 다형성을 활용하면 여러가지 객체를 각각의 상황에 맞게 같이 쓸 수 있는 것은 같이 쓰고, 각기 다르게 사용해야되는 것은 형변환을 통해 다르게 사용할 수 있을 것이다. 이렇듯 추상화를 사용하면, 구체화된 코드보다 변경에 유연하고, 생성하는데 있어서도, 유연하게 생성할 수 있을 것이다.
인터페이스
자바에서 추상화를 위해서 가장 많이 사용하는 인터페이스이다. 결론부터 말하자면 인터페이스의 핵심은 추상 메서드의 집합이다. 인터페이스와 추상클래스의 차이점을 먼저 알고 들어가는 것이 좋다. 인터페이스는 추상 메서드의 집합으로 추상 클래스에는 구현된 메서드가 있는 것과 달리, 인터페이스에는 구현된 메서드가 존재하면 안된다. 물론 JDK1.8 이후부터 static 메서드와 default 메서드를 통해서 구현이 가능하게 되었지만, 그것은 인터페이스의 본질에서 약간 벗어나기 때문에 논외로 하겠다. 이러한 차이점을 꼭 기억해야 한다. 추상 클래스와 일반 클래스와의 차이점은 추상 메서드가 있냐 없냐의 차이점이라서 인스턴스 변수도 가질 수 있었지만, 인터페이스는 가질 수 없다는 것이다. 단, 인터페이스가 가질 수 있는 변수가 하나 있는데, static으로 지정된 상수만 가능하다. 인터페이스는 다음과 같이 생겼다.
interface Flyable {
public static final int DISTANCE = 200;
public abstract void fly();
}
위 처럼 상수와, 추상 메서드로만 구성된 것을 볼 수 있다. 그리고 자세히 보면 접근제어자가 public인 것을 볼 수 있는데, 인터페이스는 무조건 구현이 되어야 하기 때문에 인터페이스 내부에 있는 상수와 추상 메서드는 무조건 public이어야 한다. 그래서 다음과 같이 생략이 가능하다.
interface Flyable {
// 상수 표현
public static final int DISTANCE = 200;
static final int DISTANCE = 200;
final int DISTANCE = 200;
int DISTANCE = 200;
// 메서드 표현
public abstract void fly();
abstract void fly();
void fly();
}
위에 있는 상수 표현방식과 아래에 있는 메서드 표현 방식 모두 동일하다. 이유는 인터페이스에서 상수와 메서드의 포맷이 정해져있기 때문에 다르게 작성하더라도 컴파일러가 알아서 변환해주기 때문이다. 단, abstract나 static 메서드 같은 경우는 분명하게 명시를 해주어야 컴파일러가 구분할 수 있기 때문에 꼭 해주어야 한다.
다음으로 추상 클래스가 다른 특징중에 하나는, 인터페이스는 다중 상속이 가능하다는 것이다.
interface Flyable {
void fly();
}
interface Attackable {
void attack();
}
class Eagle implements Flyable, Attackable {
void fly(){}
void attack(){}
}
다음처럼 다중 상속을 받을 수 있다는 말이다. 기존에 추상 클래스는 다중 상속이 안되었던 이유에 대해서 생각해보자. 다중 상속에 문제점은 상속을 해주는 부모들이 같은 메서드를 가지고 있을 경우 해당 메서드의 내용이 충돌이 되는 것이 문제가 될 수 있기 때문에, 일반적인 클래스를 상속하는 경우에는 다중 상속이 불가능하였다. 하지만 인터페이스의 특징을 다시 얘기해보자면 인터페이스는 추상 메서드의 집합이다. 따라서 클래스의 다중 상속에서의 문제점인 메서드의 내용이 겹칠 수가 없기 때문에 다중 상속이 가능한 것이다. 똑같은 메서드가 있다고 하더라도 어차피 둘 다 추상 메서드이고 둘 다 구현을 클래스에서 해줘야 동작이 가능한 메서드이기 때문이다. 그리고 기존에 몰랐었던 부분이 있었다. 인터페이스를 구현을 한다면 인터페이스에 있는 모든 추상메서드를 구현해야 한다고 알고 있었는데, 다음과 같이 가능했다.
abstract class Eagle implements Flyable, Attackable {
void fly(){}
}
지금보면 Flyable 인터페이스와 Attackable 인터페이스를 구현을 하게되면서 fly(), attack() 메서드를 구현을 해주어야 했는데, attack() 이라는 메서드를 구현을 하지 않은 것을 볼 수 있다. 이 때, fly() 라는 추상 메서드는 구현이 되어있고 attack() 이라는 추상 메서드가 아직 남아있기 때문에 이제 해당 클래스가 추상 클래스가 되는 것이다. 이렇게 하나만 구현해서 추상 클래스로 전환되는 것도 가능하다.
인터페이스의 다형성
진짜 제일 제일 중요하고 실무에서도 가장 많이 사용되는 부분이다. 다형성에 대해서는 계속 계속 반복하면서 얘기하지만, 조상의 참조변수로 자신 포함 자신의 인스턴스를 다루는 것이다. 해당 개념에 대해서 정확하게 알고 넘어가야 인터페이스의 다형성을 어떻게 이용할 수 있는지 이해 할 수 있을 것이다.
인터페이스는 구현 클래스의 부모는 아니지만 상속 받았을 때 부모 클래스와 자손 클래스간의 형변환이 가능했던 것처럼 인터페이스를 구현한 클래스는 인터페이스와 형변환이 가능하다. 다음과 같은 예시를 한 번 살펴보자.
abstract class Unit {
int x, y;
abstract public void move(int x, int y);
void stop(){}
}
interface Fightable {
void move(int x, int y);
void attack(Fightable f);
}
class Fighter extends Unit implements Fightable {
public void move(int x, int y){}
public void attack(Fightable f){}
}
다음과 같이 클래스와 인터페이스가 설계되어 있다고 할 때 Fighter가 Unit 추상 클래스를 상속받고, Fightable을 구현하고 있다고 하자. 이때 헷갈릴 수 있는 부분들에 대해서 하나하나 설명한다. 첫번째 추상클래스에도 move()가 있고, 인터페이스에도 move() 가 있는 것이 보인다. 그럼 메서드가 겹쳐서 안되는것이 아닌가 할 수 있는데, 인터페이스는 무조건 추상 메서드이다. 따라서 두개의 메서드가 같아서 겹쳤을 때에는 추상 클래스에 맞춰서 하면 될 것이다. 두번째로는 interface에 작성한 메서드가 void 타입으로 시작해서 구현 클래스인 Fighter에서 추상 메서드의 타입을 default로 했을 경우이다. 우리는 다시 한 번 오버라이딩의 규칙에 대해서 상기할 필요가 있는데, 오버라이딩의 규칙에 다음과 같은 규칙이 있다. 메서드를 상속해서 오버라이딩 할 때 부모의 접근제어자보다 더 좁은 접근 제어자를 사용할 수 없다. 그런데 우리는 인터페이스는 다음과 같이 작성해야 한다는 것을 알 고 있다.
interface Fightable {
public abstract void move(int x, int y);
public abstract void attack(Fightable f);
}
하지만 인터페이스의 모든 추상 메서드는 public이고 abstract 이기때문에 생략이 가능하다는 것도 알고있다. 따라서 그냥 default 접근제어자의 void 반환타입으로 보일 수 있지만 사실은 public abstract가 있다는 것을 잊으면 안된다. 그럼 이런 인터페이스를 사용하여 어떻게 다형성을 적용하는지에 대해서 살펴보자.
abstract class Unit {
int x, y;
abstract public void move(int x, int y);
void stop() {
System.out.println("멈춤");
}
}
interface Fightable {
void move(int x, int y);
void attack(Fightable f);
}
class Fighter extends Unit implements Fightable {
public void move(int x, int y) {
System.out.println("[Fighter] x : " + x + ", y : " + y + "로 이동");
}
public void attack(Fightable f) {
System.out.println(f + " 와 싸웁니다.");
}
@Override
public String toString(){
return "Fighter";
}
}
public class Main {
public static void main(String[] args) {
Fightable f = new Fighter();
f.move(100, 200); // 1. 정상 작동
f.attack(new Fighter()); // 2. 정상 작동
f.stop(); // 3. 작동 X
/*
Fighter f2 = (Fighter)f; // 4. 형변환
Unit u = f2; // 5. 형변환
*/
Unit u = (Fighter)f; // Unit u = (Unit)(Fighter)f;
u.move(100, 200); // 6. 정상 작동
u.attack(f); // 7. 작동 X
u.stop(); // 8. 작동 O
}
}
차근차근 들어가보자. 실습 코드를 만들면서 놀라웠던 부분이 있어서 해당 부분도 추가로 설명하면서 들어가겠다. 다형성을 통해서 Fightable 인터페이스 참조변수로 Fighter 인스턴스를 다루고 있는 모습을 볼 수 있다. 당연히 Fightable 참조변수에는 move 라는 메서드가 있고, attack 이라는 메서드가 있기 때문에 정상적으로 Fighter 인스턴스에서 구현한 메서드들이 실행되는 것을 볼 수 있는데, Fightable에는 stop() 메서드가 없기 때문에, 실행하면 컴파일러에서 오류를 잡는 것을 볼 수 있다. 이어서, Unit으로 형변환 했을 경우(현재 인스턴스가 Fighter 이기 때문에, Unit의 자손인 Fighter는 Unit으로 형변환이 가능하다.) 를 테스트 해보기 위해서 Unit으로 형변환을 해볼려고 다음과 같은 코드를 사용하였다. Unit u = f; 자손에서 부모로 형 변환을 할 때에는 명시적인 표시를 빼도 되기 때문에, 빼서 한 번 해봤는데, 컴파일 에러가 발생했고, 오류는 Fightable이 Unit으로 형변환 할 수 없다는 얘기였다. 그렇다 우리는 Fighter라는 인스턴스를 다루고 있긴 하지만 현재 Fighter라는 인스턴스를 다루고 있는 참조 타입은 Fightable이고 Fightable 인터페이스는 Unit과 그 어떠한 관계도 있지 않아서 오류가 발생한 것이였다. 그래서 위의 4번과 같이 새로운 Fighter 객체를 만들어서 형변환을 하고 다시 형변환을 해주는 총 2번의 과정을 거쳤는데, 이건 왠지 다형성의 장점에서 많이 벗어난 것 같았다. 그래서 혹시나 하는 마음에 그럼 Unit 부모로 가기전에 형변환을 먼저 해주고 다시 형변환 하는 식으로 담으면 되지 않을까? 하는 생각으로 시도를 해봤는데, 정상적으로 동작하는 것을 볼 수 있었다. 현재 Fighter 인스턴스는 Unit 클래스의 참조변수가 다루고 있고 Unit 클래스에는 attack이라는 메서드가 없다. 따라서 attack이라는 메서드를 실행하게 되면 컴파일러에서 오류가 발생한다. 이렇듯 자식의 구현타입으로 여러가지 클래스를 다형성을 통해 변환하면서 유연성있게 실행할 수 있는 모습을 볼 수 있었다. 그럼 이어서 인터페이스의 장점과 jdk1.8 이후에 나온 디폴트 메서드와 static 메서드의 적용을 한 번 살펴보자.
인터페이스의 장점
지금까지는 객체지향언어의 특징인 다형성과 추상화를 중점적으로 인터페이스를 다루었다면 이제는 인터페이스의 진짜 장점이 무엇인지 얘기해보고자 한다. 인터페이스의 정의는 다음과 같다. 두 객체 간의 ‘연결, 대화, 소통’을 돕는 ‘중간 역할’을 한다. 이것이 정의인데 이렇게 말하면 잘 와닿지 않는다. 내가 이해한 바로 저거 어떠한 그림으로 풀어내면 다음과 같다.
A → I → B 가 있을 때 A는 B를 모르더라도 I 를 통해서 B를 사용 할 수 있다. 그래서 중간 역할이라는 얘기가 나오는 것이다. 보통의 사람들이 들어봤을 예시 중에 하나는 GUI 이다. Graphic User Interface 라고 들어봤을 것이다. 원래 우리는 하드웨어에 있는 메모리에 접근을 하기 위해서 운영체제가 이를 원할하게 해주기 위해 도와준다. 하지만 운영체제도 돌아갈 때에는 컴퓨터는 1과 0 뿐이 이해를 못하기 때문에 사람들은 1과 0을 이해를 할 수 가 없다. 그래서 그나마 사람들이 이해할 수 있는 어셈블리어를 통한 CLI(Command Line Interface)를 통해서 컴퓨터 내부에 있는 하드웨어에 직접 접근해서 어떠한 작업들을 수행 할 수 있다. 그런데 CLI 또한 명령어들을 외워야하기 때문에 사람들에게 친절하지가 않다. 이를 개선하기 위해서 우리가 마우스로 클릭해서 무엇인가를 실행하는 GUI가 탄생하게 되었는데, CLI 와 GUI는 둘 다 하드웨어에 있는 보조기억장치에 있는 데이터에 접근해서 메모리 영역으로 이동시키고 이를 CPU가 읽어서 어떠한 연산 작업을 하게 해준다. 하지만 대부분의 일반인들은 저라한 과정이 어떻게 발생하는지 모르지만 우리는 똑같은 하드웨어에 접근을 해서 사용한다. 이렇게 내부의 어떠한 복잡한 로직은 몰라도 중간 매개체인 인터페이스를 통해서 접근을 하는 것이 인터페이스의 장점이다.
길게 얘기를 했는데, 예시를 참고해서 보면 더 잘 이해될 것이다. 다음과 같은 예시를 보자.
class A {
public void play(B b){
b.method();
}
}
class B {
public void method(){
System.out.println("method 실행");
}
}
public class Main {
public static void main(String[] args) {
A a = new A();
a.method(new B());
}
}
다음과 같은 예시가 있다고 가정을 해보자. 클래스 A는 B 클래스를 무조건 적으로 사용하도록 구성되어있다. 이러한 내용을 의존한다고 한다. 스프링을 공부해본 사람이라면 의존이라는 말이 되게 익숙할 것이다. 서론을 줄이고 지금 보면 A 라는 클래스가 B에 직접적으로 접근을 하고 있는데, 이와 같은 상황에서 C라는 클래스를 사용해야 되는 상황으로 변하게 되면 A라는 클래스에 있는 내용을 바꾸거나 오버로딩을 해야하는 상황이 발생한다. 다음과 같은 예시처럼 말이다.
class A {
public void play(C c){
c.method();
}
public void play(B b){
b.method();
}
}
class B {
public void method(){
System.out.println("method 실행");
}
}
class C {
public void method(){
System.out.println("method 실행");
}
}
public class Main {
public static void main(String[] args) {
A a = new A();
a.play(new B()); // 만약 오버로딩 하지 않았다면 오류 발생
a.play(new C());
}
}
만약 오버로딩을 해주지 않았다면 A 클래스에는 play(C c) 밖에 남지 않을 것이다. 그럼 실제로 실행하는 영역에서는 오류가 발생하게 될 것이다. 그러나 우리는 인터페이스를 이용하고, 인터페이스의 다형성을 이용하면 이를 깔끔하게 해결해줄 수 있는데 다음과 같은 상황을 한 번 살펴보자.
interface I {
void method();
}
class B implements I {
public void method() {
System.out.println("method 실행");
}
}
class C implements I {
public void method() {
System.out.println("method 실행");
}
}
class A {
public void play(I i){
i.method();
}
}
public class Main {
public static void main(String[] args){
A a = new A();
a.play(new B());
a.play(new C());
}
}
인터페이스를 추가함으로써 하나의 메서드로 다형성을 이용해서 인터페이스를 구현한 여러가지 객체들에게 접근을 할 수 있게 되었고, 실제로 어떠한 내부적인 로직이 바뀌더라도 우리는 A 클래스에 있는 그 어떠한 것도 바꿔줄 필요가 없다. 만약 B 혹은 C 영역에 새로운 기능이 추가되었다고 했을 때 private로 메서드를 선언하고 해당 메서드를 method() 라는 메서드 영역에서 실행이 되도록 하면 우리는 A 클래스에서 아무것도 건들지 않았음에도 불구하고 추가 된 기능까지 사용할 수 있다. 이렇게 유연하게 어떠한 사용하고자 하는 기능은 추가함에도 불구하고 실제 해당 기능을 사용하는 곳에서는 변경할 필요가 없다. 이러한 것을 좋은 객체지향 설계 원칙 중에 하나인 개방폐쇄원칙이라고 한다. 이제 이러한 인터페이스의 장점을 이용하여 실제로 무엇이 개선되고 무엇이 좋은지에 대한 결론을 보자.
- 개발 시간을 단축할 수 있다.
- 변경에 유리한 유연한 설계가 가능하다.
- 표준화가 가능하다.
- 서로 관계없는 클래스들을 관계를 맺어줄 수 있다.
이렇게 총 4가지가 있다. 먼저 1번 부터 살펴보자. 다음과 같은 예시가 있다고 하자. 예를 들어서 음료수 자판기를 예시로 들어보자. 사람이 해당 음료를 마시기 위해서는 어떤 금액을 지불을 해야하고, 금액이 지불이 되면 음료가 나와야 할 것이다. 그런데 개발 시간을 단축시키기 위해서 금액을 지불하는 메서드 구현 따로 음료가 나오는 메서드를 따로 구현하라고 한다. 근데 인터페이스를 사용하지 않고 메서드를 따로 구현해야 한다면 음료가 나오는 메서드는 금액이 지불이 되는 메서드를 기다려야 한다. 이럴 때 인터페이스가 큰 도움이 된다.
abstract class Person {
private int money;
Person(){
this(1000);
}
Person(int money){
this.money = money;
}
public int getMoney(){
return this.money;
}
}
class Student extends Person {
public Student(){}
public Student(int money){
Super(10000);
}
}
interface Payment {
boolean pay(Person p, Drink d);
}
class Bill implements Payment {
public boolean pay(Person p, Drink d) {
if(d.getMoney() - p.getMoney() > 0)
return true;
}
}
class Card implements Payment {
public boolean pay(Person p, Drink d) {
if(d.getMoney() - p.getMoney() > 0)
return true;
}
}
class VendingMachine {
public boolean drawingDrink(Payment p){
if(p.pay())
return true;
return false;
}
}
예시가 초라하긴 하지만 다음과 같은 상황을 살펴보자. 사람들마다 각각의 상황에 따라 가진 돈이 다를 것이고 지불 방식 또한 다를 것이다. 지불 방식에 대해서도 카드일 경우 현금일 경우에 대해서도 각각의 결제 방식이 달라져야 하는데 Payment라는 인터페이스를 통해서 결제를 하는 메서드는 통일을 했고 비록 위에 있는 메서드는 구현 방식이 동일하지만 실제로 내부에 돌아가는 구현 동작은 달라져도 문제가 없다. 그런데 이렇게 지불방식이 구현이 안된 상황에서 자판기가 음료수를 꺼내야 하는데 해당 메서드들이 구현될 때 까지 기다릴 수는 없다. 따라서 어떤 지불방식이 오던 상관없이 Payment라는 인터페이스를 통해서 Payment 인터페이스 내부에 있는 pay라는 메서드를 통해서 지불이 정상적으로 수행되면 음료수가 나오도록, 지불이 정상적으로 수행되지 않으면 음료수가 나오지 않도록 설계를 해두었다. 이렇게 변경에도 유연하게 설계를 할 수 있고, 각각의 팀이 어떠한 기능을 나눠서 개발을 수행할 수 있기 때문에 개발시간을 단축할 수 있다. 심지어 표준화도 가능하다.
그럼 이어서 서로 관계없는 클래스들의 관계를 맺어줄 수 있다는 의미는 어떤 의미인지 한 번 살펴보도록 하자.
abstract class Unit {
int x, y;
int hp;
public Unit(){}
public Unit(int hp){
this.hp = hp;
}
void move(int x, int y);
void stop() {
System.out.println("멈춤");
}
}
interface GroundUnit {
}
interface FlyableUnit {
}
class SCV extends Unit implements GroundUnit {
public SCV(){
Super(60);
}
@Override
void move(int x, int y){
System.out.println(this + "가 x : " + x + ", y : " + y + "로 이동하였습니다.");
}
@Override
String toString(){
return "SCV";
}
}
class Marine extends Unit implements GroundUnit {
public Marine(){
Super(65);
}
@Override
void move(int x, int y){
System.out.println(this + "가 x : " + x + ", y : " + y + "로 이동하였습니다.");
}
void stimpack
@Override
String toString(){
return "Marine";
}
}
class Tank extends Unit implements GroundUnit {
public TANK(){
Super(120);
}
@Override
void move(int x, int y){
System.out.println(this + "가 x : " + x + ", y : " + y + "로 이동하였습니다.");
}
@Override
String toString(){
return "TANK";
}
}
class Dropship extends Unit implements FlyableUnit {
public Dropship(){
Super(130);
}
@Override
void move(int x, int y){
System.out.println(this + "가 x : " + x + ", y : " + y + "로 이동하였습니다.");
}
@Override
String toString(){
return "Dropship";
}
}
위와 같은 예시가 있다. 인터페이스를 활용해서 서로의 역할과 구분을 나눠두었다. 그런데 여기서 이 게임에서 다음과 같은 설계를 하기를 원하는 것이다. 게임을 진행하면서 기계들만 수리를 가능하게 해줘! 라는 요청을 받았는데 현재 상황에서는 유닛, 지상유닛, 공중유닛으로 밖에 나뉘어져있지 않다. 이럴 경우에 인터페이스를 활용해서 관계가 없는 것들을 한데 묶을 수 있다. 다음과 같은 상황을 보자.
interface Repairable {}
class Dropship extends Unit implements FlyableUnit, Repairable {
public Dropship(){
Super(130);
}
@Override
void move(int x, int y){
System.out.println(this + "가 x : " + x + ", y : " + y + "로 이동하였습니다.");
}
@Override
String toString(){
return "Dropship";
}
}
class Marine extends Unit implements GroundUnit {
public Marine(){
Super(65);
}
@Override
void move(int x, int y){
System.out.println(this + "가 x : " + x + ", y : " + y + "로 이동하였습니다.");
}
void stimpack
@Override
String toString(){
return "Marine";
}
}
class SCV extends Unit implements GroundUnit, Repairable {
public SCV(){
Super(60);
}
@Override
void move(int x, int y){
System.out.println(this + "가 x : " + x + ", y : " + y + "로 이동하였습니다.");
}
void repair(Repairable r){
if(r instanceOf Unit){
Unit u = (Unit)r;
u.hp += 3;
}
}
@Override
String toString(){
return "SCV";
}
}
위 처럼 Repairable 이라는 인터페이스를 만들고, 수리가 가능한 객체들에게 역할을 부여하였고, 수리는 SCV만 가능한 고유의 기술이다. 만약 Repairable 이라는 인터페이스 메서드 참조 변수에 Marine이 들어오게 되면 수리가 되지 않고, 드랍쉽 혹은 탱크, SCV가 오면 수리가 되는 방식으로 작동한다. 이렇게 원래 관계가 없었음에도 불구하고 관계를 만들어서 동작 가능하도록 하는 것이 인터페이스의 큰 특징이고 이것을 나중에 디자인 패턴에서 Adapter 패턴으로 배운다.
default 메서드, static 메서드
default 메서드와, static 메서드는 JDK1.8 부터 추가된 내용이다. 별로 어려운 내용은 아니고 인터페이스에 static 메서드를 사용할 수 있게 된 부분이다. 어차피 static 메서드는 애플리케이션이 실행 될 때 메모리에 올라가면서 메소드 영역에 초기화가 되기 때문에, 문제가 없어서 개선해달라는 말이 많아서 생긴 것이고, 여기서 문제는 default 메서드인데, 제어자인 default를 메서드 앞에 붙이면 인터페이스에서도 메서드를 구현할 수 있다. default 메서드가 생긴 이유는 인터페이스에 어떠한 기능을 추가하게 되면 해당 인터페이스를 구현하고 있는 모든 클래스에 추가된 메서드를 전부 다 구현해줘야하는 문제가 있어서 생긴 것이다. 그리고 인터페이스에 메서드를 구현하는 방식은 다음과 같다.
interface Genga {
void play();
default boolean isValidRule(){
return true;
}
}
위 처럼 가능하다는 말인데, 우리가 인터페이스를 사용함으로써 다중 상속이 이루어졌을 때의 문제를 해결할 수 있었다. 그러나 위 처럼 작성했을 경우 다시 동일한 isValidRule 이라는 메서드를 다른 곳에서도 사용 중일 때 메서드가 중복되는 문제가 있는데 이럴때는 다음과 같은 방식으로 해결된다.
- 인터페이스 간의 default 메서드가 중복되어 충돌 됐을 경우
- 조상메서드와 default 메서드가 충돌 됐을 경우
이렇게 두 가지가 있는데 1번 같은 경우는 오버라이딩을 하면 해결이 되고 2번 같은 경우는 default 메서드는 무시하고 조상 메서드가 상속이 된다. 그런데 이렇게 두 가지 케이스의 예외 충돌 상황을 딱히 걱정하지 않고, 그냥 겹치는 상황에서는 오버라이딩을 하는 것이 안전하고, 그렇게 큰 고민을 할 필요가 없다. 그리고 default 메서드를 추가하기 보다는 새로이 어떠한 행동에 대해서 새로 인터페이스를 추가하고 그 인터페이스를 구현하는 구현체를 따로 만드는 것이 옳은 방식이다. 최대한 행동에 대해서 새로운 기능이 추가되었다는 것은 사실 그거 자체가 다른 역할을 동시에 도맡아서 하는 것일 수도 있기 때문이다.
'Language > Java' 카테고리의 다른 글
4-2 자바 반복문 (0) | 2023.03.23 |
---|---|
4-1 자바 조건문 (0) | 2023.03.23 |
7-5 캡슐화 (0) | 2023.03.23 |
7-4 제어자 (0) | 2023.03.23 |
7-3 패키지 (0) | 2023.03.22 |