[OOP] 객체지향의 특징 - 다형성(Polymorphism)

2022. 10. 3. 16:10TIL💡/Design Pattern

다형성(Polymorphism)

객체지향 언어는 동일한 이름을 가진 메소드를 허용하지 않는다.

예를 들어, "먹는다"는 동작이 구현된 메소드가 있다고 가정하자.

먹는다는 동일한 동작이 구태여 두 개나 구현될 필요는 없다.

이러한 관점에서 본다면 메소드의 고유 아이덴티티라고도 불릴 수 있는 메소드명의 유니크화는 어쩌면 당연하다.

 

하지만 조금 생각해보면 좀 이상하다.

JAVA는 타입에 죽고 타입에 산다.

Javascript와 달리 파라미터에 아무타입이나 넣을 수 없기 때문에, 정해진 타입 이외의 무언가를 넣으면 컴파일 단계에서 가차없이 컷한다.

 

One for One!
하나의 파라미터는 반드시 하나의 타입만을 가진다.

그말인즉슨, 동일한 메소드는 존재할 수 없으니, 해당 메소드에 입력할 수 있는 각각의 파라미터 타입도 하나로 고정된다.

다형성을 통해 이러한 번거로움을 덜어낼 수 있다.

 

class TV
{
    // 메소드
}
class SmartTV extends TV
{
    // 메소드
}

SmartTV는 TV를 상속받아 구현된 객체이다.

public class Main
{
    public static void main(String[] args)
    {
        // 객체와 인스턴스 타입 일치
        TV tv = new TV();
        // 객체와 인스턴스 타입 일치
        SmartTV smart = new SmartTV();
        // SmartTV는 TV의 자식 객체이므로 다형성이 적용되어 허용
        TV tv2 = new SmartTV();
        // 불가능
        SmartTV smart2 = new TV();
    }
}

TV와 SmartTV는 엄연히 다른 객체임에도 불구하고 인스턴스가 정상적으로 생성된다.

이는 객체의 다형성이 적용된 결과로, SmartTV는 TV를 상속받아 만들어진 객체다.

즉 SmartTV는 TV를 온전히 포함하고 있으므로 TV의 인스턴스로 생성이 가능하다.

이러한 객체의 다형성은 객체를 상속했을 때뿐만 아니라 인터페이스를 상속할 때도 가능하다.

 

다형성이 적용된 인스턴스

객체의 다형성을 다룰 때 주의할 점이 한 가지 있다.

다형성을 통해 인스턴스가 생성되긴 했는데, 과연 이 인스턴스는 어떻게 작동할까?

 

정답은 tv2는 SmartTV로 생성됐어도, 선언된 TV와 일치하는 메소드만 사용 가능하다.

 

이번엔 인터페이스를 예시로 들어보자.

움직임에 대한 동작이 기술되어 있는 인터페이스 Movable과 이를 상속받은 Unit 객체가 있다.

interface Movable
{
    void move(boolean direction);
}
class Unit implements Movable
{
    @Override
    public void move(boolean direction)
    {
        // 동작
    }
    public void work(String act)
    {
        // 동작
    }
}
public class Main
{
    public static void main(String[] args)
    {
        Movable movable = new Unit();
        // Movable에 존재하는 메소드이므로 호출 가능
        movable.move(true);
        // Movable엔 없는 Unit만의 고유 메소드이므로 호출 불가능
        movable.work("run");
    }
}

객체의 다형성으로 인해 Unit 객체를 Movable로 생성할 수 있음은 잘 알 것이다.

Movable이라는 인스턴스를 만들고 move(), work() 메소드를 각각 호출해본다.

move()의 경우 Movable 인터페이스에서 상속받아 구현한 메소드고, work()는 Unit에서 직접 생성한 메소드다.

이 경우 Unit의 메소드를 호출할 수 있지만, Movable에 선언된 메소드만 호출 가능하다.

 

즉, Unit과 Movable 객체 간에 겹치는 메소드만 사용이 가능하다.

대신 오버라이딩한 메소드는 유효하기 때문에 오버라이딩한 메소드로 작동한다...!!💙💛💙💛(매우 중요)

만약 그렇지 않는다면 isinstanceof 를 써서 매번 어떤 클래스인지 확인한 후 각자 처리해줘야 한다는 번거로움이 있기 때문이 아닐까...?

그리고 업캐스팅한 클래스의 getClass()를 호출해봐도 인스턴스를 생성한 클래스(자식 클래스)대로 나온다.

 

조금 더 자세히 알기 위해서는 업캐스팅과 다운캐스팅에 대한 좋은 예시가 있다.

 

이렇게 객체의 다형성을 사용하면 동일한 객체를 상속받은 여러 객체들을 다루기 매우 편리하다.

class UnitA implements Movable
{
	@Override
	public void move(boolean direction)
	{
		work("run");
	}
    
	private void work(String act)
    {
    	System.out.println("work: " + act);
    }
    
}

class UnitB implements Movable
{
    	@Override
        public void move(boolean direction)
        {
        	doing(3);
        }
         
        private void doing(int num)
        {
        	System.out.println("doing: " + num);
        }
    }
}

위처럼 동일한 인터페이스 Movable을 상속받은 여러 객체가 있다고 가정하자.

이 객체들은 각각 개별적인 객체이지만, Movable을 상속받았으므로 세 객체 모두 다형성을 통해 Movable 인스턴스로 할당할 수 있다.

public class Main
{
	public static void main(String[] args)
    {
    	Movable movable = switch (new Random().nextInt(3))
        {
        	case 0 -> new UnitA();
            case 1 -> new UnitB();
            case 2 -> new UnitC();
            default -> null;
		};
        
        movable.move(true);
	}
}

실행 시마다 UnitA, UnitB, UnitC 중 무작위로 선택된 객체의 인스턴스를 Movable에 할당한다.

서로 같은 객체임에도 Movable 이라는 부모 객체로 인스턴스를 할당하여 공통된 메소드를 호출할 수 있다.

호출된 공통 메소드인 move() 내부에는 Unit 고유의 메소드가 포함되어도 상관없다.

 

이처럼 메소드의 입력으로 여러 타입의 파라미터가 와야할 경우, 이 파라미터들이 동일한 객체를 상속하고 있다면 다형성을 적용하여 공통된 타입으로 다룰 수 있다.

 

메소드의 다형성

메소드 역시 다형성을 적용할 수 있다.

객체의 다형성은 객체 자신의 타입과 연관되지만, 메소드의 다형성은 메소드가 사용하는 파라미터의 타입과 연관된다.

 

메소드의 다형성은 메소드가 서로 동일한 이름을 가지더라도, 입력받는 파라미터가 다르면 각각 개별적인 메소드로 취급함을 의미한다.

다형성의 존재로 인해 코드의 일관성을 유지할 수 있다. 대표적으로 우리가 콘솔에 사용하는 System.out.println() 메소드가 이에 해당한다.

public void println(float x) {
    if (getClass() == PrintStream.class) {
        writeln(String.valueOf(x));
    } else {
        synchronized (this) {
            print(x);
            newLine();
        }
    }
}
public void println(double x) {
    if (getClass() == PrintStream.class) {
        writeln(String.valueOf(x));
    } else {
        synchronized (this) {
            print(x);
            newLine();
        }
    }
}
public void println(char[] x) {
    if (getClass() == PrintStream.class) {
        writeln(x);
    } else {
        synchronized (this) {
            print(x);
            newLine();
        }
    }
}
public void println(String x) {
    if (getClass() == PrintStream.class) {
        writeln(String.valueOf(x));
    } else {
        synchronized (this) {
            print(x);
            newLine();
        }
    }
}

위 소스는 System.out.println() 의 내부 소스다. 보다시피 이름이 동일하고, 동작까지도 콘솔에 출력하는 것으로 동일하지만 다형성으로 인해 각각의 메소드가 온전한 하나로 인정된다.

 

만약 다형성이라는 개념이 없다면 어떨까? 동일한 동작을 함에도 매개변수가 달라진다는 이유만으로 비슷한 이름을 가진 메소드를 만들어야 하고, 개발자는 각 매개변수에 맞게 메소드를 사용해야 한다.

 

public void printlnFloat(float x) {
    if (getClass() == PrintStream.class) {
        writeln(String.valueOf(x));
    } else {
        synchronized (this) {
            print(x);
            newLine();
        }
    }
}
public void printlnDouble(double x) {
    if (getClass() == PrintStream.class) {
        writeln(String.valueOf(x));
    } else {
        synchronized (this) {
            print(x);
            newLine();
        }
    }
}
public void printlnChar(char[] x) {
    if (getClass() == PrintStream.class) {
        writeln(x);
    } else {
        synchronized (this) {
            print(x);
            newLine();
        }
    }
}
public void printlnString(String x) {
    if (getClass() == PrintStream.class) {
        writeln(String.valueOf(x));
    } else {
        synchronized (this) {
            print(x);
            newLine();
        }
    }
}

즉, 위와 같은 설계가 강요된다. 코드를 설계하다보면 동일한 동작에 다양한 종류의 객체가 와야할 수도 있다.

JAVA는 하나의 매개변수 = 하나의 타입이라는 원칙을 고수하므로, Javascript와 같이 다양한 종류의 타입이 매개변수로 올 수 없다.

 

다형성을 활용하면 이러한 문제를 효과적으로 타개할 수 있다. 동일한 이름으로 다양한 매개변수를 받는 메소드를 작성하면, 개발자는 이를 사용 시 별다른 타입 구분 없이 마치 동일한 메소드를 사용한다는 개발 경험을 제공한다.

 

// println(String x)
System.out.println("text");
// println(double x)
System.out.println(1.5D);

위와 같이 개발자가 별도로 타입을 구분하지 않고 사용해도, 컴파일 시 해당 매개변수를 받는 메소드가 자동으로 호출된다.

 

반환값이 다른 메소드의 다형성?

호기심이 많다면 이런 케이스를 생각해볼 수 있다. 매개변수에 대한 다형성이 있으면, 메소드의 반환값에 대한 다형성도 있지 않을까?

좋은 발상이지만, 아쉽게도 다형성은 반드시 매개변수로만 구분한다.

반환값의 경우 다형성이 적용되지 않는다.

 

마무리

객체의 다형성은 생산성에 초점이 맞춰져 있다. 동일한 메소드로 여러 타입의 데이터를 처리하거나, 공통 상속된 객체를 처리함으로써 중복된 코드 요소를 제거하고 개발 편의성을 높여준다.

 

다형성을 적극적으로 활용하여 중복된 코드는 줄이고 데이터의 범위를 넓힐 수 있다.

 

 

 참고

- https://blog.itcode.dev/posts/2021/08/12/polymorphism

- https://danmilife.tistory.com/23