다형성이란?
- 하나의 객체가 여러 가지 타입을 가질 수 있는 것을 의미한다. 하지만 아무 관계도 없는 타입을 참조할 수는 없고, 상속(클래스, 인터페이스 모두) 관계에서 구현 가능하다.
- 다형성을 활용하면 부모 클래스가 자식 클래스의 동작 방식을 알 필요 없이 자식 클래스에 접근해 필요한 메서드를 실행시킨다.
- 자식 클래스를 부모 클래스 타입으로 객체를 선언하고 오버라이딩된 메서드를 실행하면 자식 클래스의 메서드가 실행됨.
- 하지만 부모 클래스를 자식 클래스 타입으로 객체를 선언할 수는 없다.
객체지향의 4가지 특징 중 다형성은 특히나 글로 읽었을 때 이해가 되지 않는 것 같다.
여러 가지 예제와 비유로 다형성을 100% 이해해 보자
먼저, 하나의 객체가 여러 타입을 가지면서 생기는 이점이 있으려면, 오버라이딩 개념에 대해서 알아야 하고,
상속관계와는 관련 없어서 다형성의 범주에 애매하게 걸쳐있는 오버로딩도 함께 알아보자.
오버라이딩과 오버로딩의 개념과 어원
- 오버라이딩 : 같은 메서드 이름으로 같은 매개변수 목록으로 상위 클래스의 메서드를 재정의하는 행위
- 오버로딩 : 같은 메서드 이름으로 다른 매개변수 목록으로 메서드들을 중복 정의하는 행위
오버로딩과 오버라이딩을 쉽게 이해하는 방법으로 스프링 입문을 위한 자바 객체 지향 원리와 이해 책에서 나온 인공위성 비유가 있다.
인공위성이 바라보는 지구는?
사람이 자전거를 타고(override) 있고, 그 옆에는 트럭에 상자가 나란히 적재(overload) 되어있는 상황이다.
만약에 지구와 멀리 떨어진 인공위성에서 이를 본다면?
위의 사진과 같을 것이다.
인공위성 시점에서는 자전거 탄 사람은 사람만 보일 것이고, 트럭에 적재된 상자들은 다 볼 수 있다.
이게 오버라이딩과 오버로딩을 어떻게 설명한다는 건지 자세히 알아보자
오버라이딩(overriding)
방금의 비유가 오버라이딩과 오버로딩을 어떻게 설명하냐면,
인공위성을 내가 짠 코드에서 변환된 java 바이트 코드를 직접 실행하는 JVM이라고 하고
부모클래스의 함수가 자전거 자식클래스에서 자전거를 오버라이딩한 함수가 사람으로,
JVM은 자전거를 override 한 사람만 보이므로 자식클래스의 오버라이딩된 함수를 실행시킨다.
아래 코드를 보자.
public class language {
public void whatICanDo(){
System.out.println("anything!");
}
}
public class python extends language {
@Override
public void whatICanDo() {
System.out.println("ai, data analysis");
}
}
여기서 language 클래스가 부모 클래스, python 클래스가 자식 클래스이며,
오버라이딩된 함수 whatICanDo가 자전거이자, 사람인 것.
language python = new python();
python.whatICanDo();
위와 같이 작성된 코드가 실행되면
language 참조 변수 타입의 python 변수에 할당된 실제 객체의 타입은 python이므로,
런타임에서 python 인스턴스의 whatICanDo함수는 실제 객체 타입의 whatICanDo를 실행시키게 된다.
그러면 위의 예시에서 language 클래스의 whatICanDo() 함수가 인공위성은 보지 못하는 자전거가 된 것이고, python 클래스의 whatICanDo() 함수가 인공위성이 볼 수 있는 사람이 된 것이다.
오버로딩(overloading)
위에서 얘기한 나란히 적재된 상자들을 떠올려, 아래의 두 sum 함수를 각각의 상자라고 하자
public float sum(int a, int b){
return (float)(a+b);
}
public float sum(float a, float b){
return a+b;
}
어떤 상자를 선택할지 고민한다는 것은, "어떤 함수를 실행시킬지는 매개변수의 타입을 보고 결정하겠다." 라는 것과 같다.
가능한 모든 매개변수 조합에 대해서 정의를 해놓는다면, 개발자는 매개변수가 어떤 타입이어야 했는지에 대한 고민 같은 건 하지 않고 작업을 할 수 있게 된다. (물론 실제로는 모든 매개변수조합들을 다 정의하는 게 아니라 제네릭 형식을 사용하겠지만.)
클래스의 상속으로 알아보는 오버라이딩 및 오버로딩
앞서 말했듯이 다형성이라는 성질은 예제로 직접 코딩을 해보면서 알아보는 것이 좋다.
오버라이딩 예제
프로그래밍 언어별로 뭘 할 수 있는지 출력하는 예제로 오버라이딩과 오버로딩을 맛보자
아래 코드를 보자
public class language {
public void whatICanDo(){
System.out.println("anything!");
}
}
public class python extends language {
@Override
public void whatICanDo() {
System.out.println("ai, data analysis");
}
}
public class java extends language{
@Override
public void whatICanDo() {
System.out.println("web, app");
}
}
프로그래밍 언어가 공통적으로 가지는 속성을 정의한 language 클래스, 이를 상속받은 python, java클래스가 있고, whatICanDo() 함수는 오버라이드 되었다.
이제 실제로 whatICanDo() 메서드들을 실행시켜 보자.
아래 작성된 코드에서 다형성의 ‘상속관계에서 하나의 객체가 여러 개의 타입을 가리킬 수 있다.’는 특징을 볼 수 있게 된다.
상속관계이기 때문에, language 참조타입의 변수로 lang, python, java를 모두 선언했고, 실제로 가리키는 객체는 각각 language, python, java를 가리키도록 했다.
함수를 직접 실행시켜 보자!
//python python= new python();
//위와 같이 python의 형태를 python이라고 명시하지 않고
//language라고 해도 python 클래스의 whatICanDo 함수가 실행된다.
language lang = new language();
language python = new python();
language java = new java();
lang.whatICanDo();
python.whatICanDo();
java.whatICanDo();
>>>
anything!
ai, data analysis
web, app
실행 결과, 모두 language 타입으로 선언된 객체들인데, 예상대로라면 whatICanDo() 메서드를 실행한 3줄 코드의 결과가 모두 anything! 이여야 했지만, 실제는 각각 다른 값이 나왔다.
이는 실행될 함수가, 인스턴스의 참조타입에 의존하는 것이 아니라, 실제 가리키는 객체의 타입, 즉 자식 클래스의 메서드가 우선되어 실행됨을 의미한다.
방금 예제로 다형성에 대해서 우리가 알게 된 것은?
ai, data analysis
의 결과를 얻기 위해서 python 인스턴스를 꼭 python 형식으로 선언하지 않아도 된다는 것이다!
조금 더 자세히 이해해 보자면,
python의 인스턴스의 whatICanDo 함수를 실행할 때는 다음 과정을 거친다.
- python 변수의 형식은 language이므로, language 클래스의 정의를 확인한다.
- language클래스 안에는 whatICanDo 함수가 있다.
- language 클래스를 상속받은 python 클래스에는 whatICanDo를 override 한 whatICanDo 함수가 있다. 그것을 실행!
오버로딩 예제
오버로딩은 간단하게 아래 Soccer 클래스를 보면 이해할 수 있다.
public class Soccer extends Sports {
public String play() {
return "발로 공을 찬다.";
}
public String play(String how){
return how + play();
}
}
다형성의 범주에 오버로딩이 끼는 이유는 개발하는 입장에서, 어떻게 축구를 하든, 그냥 축구를 하든 어쨌든 play 하는 거니까, 개발자입장에서 많은 것을 기억하지 않아도 되도록,
둘 다 play라는 함수로 정의하고, 매개변수를 넣거나, 문자열 한 개를 넣었을 때, 개발자가 의도한 로직이 동작하도록 미리 누군가 중복 정의를 해 둔 것이다.
그렇게 하면 똑같은 함수를 사용하지만, 다른 매개변수를 대입하고, 최종적으로는 비슷한 함수의 실행을 기대할 수 있다.
(오버로딩된 함수의 내부 로직이 달라 비슷한 맥락에서 사용할 수 없다면, 그건 다형성이 아니라 암살 시도이다.)
오버로딩 사용 시 주의해야 하는 부분
오버로딩은 상속관계에서 재정의하는 오버라이딩과 달리 그냥 같은 클래스 내에서 중복(수평적)으로 정의하는 것이다.
하지만 상속관계에서는 오버로딩 사용 시 주의할 점이 있는데, 예제로 살펴보자
public class Sports {
public String play(){
return "안다치게 한다.";
}
}
public class basketball extends Sports{
@Override
public String play(){
return "공을 골망에 던진다." ;
}
}
public class Soccer extends Sports {
@Override
public String play() {
return "발로 공을 찬다.";
}
public String play(String how){
return how + play();
}
}
Sport 참조변수로 선언되는, 각 운동을 대표할 객체들을 생성하고 play 해보자
Sports sports = new Sports();
System.out.println(sports.play());
>>> 안다치게 한다
// Sports 형으로 Soccer와 basketball 인스턴스를 할당
Sports soccer = new Soccer();
Sports basketball = new basketball();
System.out.println(basketball.play());
System.out.println(soccer.play());
// 아래 코드는 오류가 발생한다.
System.out.println(soccer.play("아주 세게"));
>>>
공을 골망에 던진다.
발로 공을 찬다.
컴파일러 에러
Soccer realSoccer = new Soccer();
System.out.println(realSoccer.play());
System.out.println(realSoccer.play("아주 세게"));
>>> 발로 공을 찬다.
>>> 아주 세게 발로 공을 찬다.
위 코드에서 오류가 발생되는 코드인
System.out.println(soccer.play("아주 세게"));
는 아래와 같은 이유로 컴파일러 오류를 표시한다.
- Sports 클래스 안에는 String 매개변수를 1개 필요로 하는 play 함수가 없다!
- 코딩하는 사람이 뭘 실수했겠지, 컴파일러 오류로 수정하도록 시키자.
인간의 느낌대로 알아서 Soccer 클래스 안의 문자열을 매개변수로 받는 play함수를 실행해 주면 좋을 텐데, MBTI가 T인 우리의 JVM은 그래 줄 생각이 없다.
Sports soccer = new Soccer();
System.out.println(soccer.play("아주 세게"));
다시 생각해 보면 당연하지만,
public class Sports {
public String play(){
return "안다치게 한다.";
}
}
public class Soccer extends Sports {
@Override
public String play() {
return "발로 공을 찬다.";
}
public String play(String how){
return how + play();
}
}
play(String how) 함수는 상속 관계에서 아무 관계가 없는, 자식 클래스만 가지고 있는 함수이므로, 컴파일러가 오류를 나타내는 게 그리 이상하지 않다.
아래처럼 구현했다면 오류는 발생하지 않았을 것이다.
public class Sports {
public String play(){
return "안다치게 한다.";
}
public String play(String how){
return how + "안다치게 한다.";
}
}
public class Soccer extends Sports {
@Override
public String play() {
return "발로 공을 찬다.";
}
@Override
public String play(String how){
return how + play();
}
}
주의!!!
하지만 이런 구현방식은 손도 많이 가고 뭔가 중복으로 많은 걸 작성한다는 점에서 정답이 아니라는 것을 느낀 사람도 있을 것이다.
그 느낌이 옳다. 이것이 왜 옳은 구현이 아니고, 왜 이렇게 꼬이게 되었는지 생각해 보자
public class Sports {
public String play(){
return "안다치게 한다.";
}
}
public class Soccer extends Sports {
@Override
public String play() {
return "발로 공을 찬다.";
}
public String play(String how){
return how + play();
}
}
가장 먼저, 꼭 필요한 상황이 아닐 때, 어떤 메서드를 오버라이드하고 있는 자식 클래스에서 해당 클래스의 매개변수 입력값이 다른 함수를 작성한 것이 문제였다.
“어떻게” 해당 운동을 할 지에 대해서 출력하는 것은, 운동이 갖고 있는 성질이지, 축구만 그것을 가질 이유가 없고, 이것이 일반적이다.
그런데 이미 자식 클래스에 메서드를 overload 해 놓고 이를 해결해 보려다가 엉겁결에 부모 클래스에 함수를 똑같이 작성해서 상속관계를 억지로 만들려고 하게 된 것이다. 아래처럼.
public class Sports {
public String play(){
return "안다치게 한다.";
}
public String play(String how){
return how + "안다치게 한다.";
}
}
public class Soccer extends Sports {
@Override
public String play() {
return "발로 공을 찬다.";
}
@Override
public String play(String how){
return how + play();
}
}
이런 문제를 겪지 않기 위해 overload는 우선 부모 클래스에서 하도록 하자.
public class Sports {
public String play(){
return "안다치게 한다.";
}
public String play(String how){
return how + this.play();
}
}
public class Soccer extends Sports {
@Override
public String play() {
return "발로 공을 찬다.";
}
}
이렇게 하고 보니 약간의 궁금증이 든다.
아까 오류가 났던 부분을 다시 살펴보자
Sports soccer = new Soccer();
// 이제 컴파일러 오류는 당연히 사라졌다.
System.out.println(soccer.play("아주 세게"));
>>>
아주 세게 발로 공을 찬다.
만약 지금까지 다형성을 100% 이해한 게 아니라면, 위 코드의 실행 결과가 부모 클래스 안에 있는 두 play함수만 실행되어서
아주 세게 안다치게 한다.
라는 이상한 결과가 나올 것이라고 예상했을 수도 있다.
하지만 java의 this 키워드에 대해서 생각해 보면 다음과 같은 과정을 통해 위의 출력을 이해할 수 있다.
- 참조 형식인 Sports 클래스 안에 문자열 매개변수를 한 개 받는 play 함수를 찾는다.
- play 함수를 참조형식의 클래스에서 찾았고, 이는 실제 객체에서 오버라이드 되지 않았다.
- 부모클래스의 play(String how) 함수가 실행되었고, 그 내부에서 this.play() 함수가 실행되었다.
- this는 soccer 변수의 실제 객체로, Soccer를 가리키므로, Soccer 클래스의 play() 함수가 실행된다.
이전의 play(String how) 함수가 실행될 때는 실제 객체에 해당하는 함수가 없어서 부모 클래스에서 실행되었지만, 함수를 실행하는 주체가 참조 형식의 Sports 에게로 완전히 넘어갔다고 생각하면 안 된다.
오버로딩은 다형성의 성질을 나타내는 게 아니라고?
사실은 오버로딩은 다형성의 일부가 아니라는 얘기가 좀 있다.
그렇지만 우리는 위에서 알아본 결과 다형성이 가져다주는 이점을 오버라이딩뿐만이 아니라 오버로딩으로도 비슷하게 얻을 수 있다는 것을 알았다.
왜 비슷하다고 했냐면, overriding을 사용함으로써 하나의 객체가 여러 가지 타입의 객체로 사용될 수 있는 부분이 다형성을 잘 나타내는 성질인데, overloading은 결국 overriding을 사용하지 않으면 결국 하나의 함수를 사용한 것처럼 보이게 작성한 코드가 여러 개의 함수를 가리키는 만큼의 다형성을 제공하기 때문이다.
오버로드와 다형성이 결국 지향하는 바가 궤를 달리하는 정도는 아닌 것 같으니 우리 오버로드도 다형성에 끼워주자..
여기까지 오버라이딩과 오버로딩을 한 번에 다루어 다형성에 대해서 알아보았다.
다형성을 제대로 이용하려면?
자식 클래스의 인스턴스를 부모 클래스 형식으로 선언이 가능하고, 그렇게 선언할 때 왜 유리한지 예제로 알아보자.
다양한 프로그래밍 언어로 알고리즘 문제를 풀어보는 상황을 보여주는 예제이다.
public abstract class language {
public void whatICanDo(){
System.out.println("anything!");
}
protected String lang;
public language(){
this.lang = "None";
}
protected String thinkOfLogic(String prob){
return lang + "언어로는 " + prob + "을(를) 어떻게 하더라?";
}
public abstract void solve(String prob);
}
public class java extends language{
@Override
public void whatICanDo() {
System.out.println("web, app");
}
public java() {
this.lang = "java";
}
@Override
public void solve(String prob) {
thinkOfLogic(prob);
if(prob == "정렬"){
System.out.println("list.sort((a1,a2)->{ \\n" +
" if(a1[0]==a2[0])\\n" +
" return a1[1] - a2[1];\\n" +
" return a1[0]-a2[0];");
}
}
}
public class cpp extends language {
public cpp() {
this.lang = "cpp";
}
@Override
public void solve(String prob) {
thinkOfLogic(prob);
if(prob == "정렬"){
System.out.println("sort(vector.begin(), vector.end(), compare)\\n" +
"bool compare(vector<int> a, vector<int> b) {\\n" +
" if (a[0] == b[0]) return a[1] < b[1];\\n" +
" return a[0] < b[0];\\n" +
"}");
}
}
@Override
public void whatICanDo() {
System.out.println("simulation");
}
}
public class python extends language {
@Override
public void whatICanDo() {
System.out.println("ai, data analysis");
}
public python() {
this.lang = "python";
}
@Override
public void solve(String prob) {
//thinkOfLogic(prob);
System.out.println(this.lang + "은 그냥 하면 된다.");
if(prob == "정렬"){
System.out.println("list.sort()");
}
}
}
아래 코드에서처럼 내가 사용 가능한 프로그래밍 언어들의 배열을 선언하여, language 클래스와 상속관계에 있는 클래스 python, java, cpp의 객체들을 하나의 배열에 요소로 넣을 수 있다.
// 세 언어 모두 language로 선언
language python = new python();
language java = new java();
language cpp = new cpp();
// 프로그래밍 언어들의 배열을 생성
// 모두 같은 language 형이므로 배열에 잘 ㄷ르어간다.
language[] langs = new language[3];
langs[0] = python;
langs[1] = java;
langs[2] = cpp;
이제 생성한 프로그래밍 언어 배열을 순회하며 프로그래밍 언어들이 각각 뭘 할 수 있고, 정렬을 이용한 문제를 풀 때 어떤 생각을 하고 어떻게 정렬을 할 수 있는지 시켜보자!
// 아래는 다형성의 이점을 100%활용하지 않은 사용
langs[0].whatICanDo();
langs[0].solve("정렬");
langs[1].whatICanDo();
langs[1].solve("정렬");
langs[2].whatICanDo();
langs[2].solve("정렬");
단순화한 예시라서 잘 와닿지 않을 수도 있지만, 위처럼 사용하면 다형성의 이점을 100% 사용한 것은 아니게 된다. 위처럼 개발하면 아래와 같이 사람이 다 langs 배열에 있는 요소가 뭔지 알고 쓰는 것이나 다름없다.
마치 아래의 코드처럼 말이다.
python.whatICanDo();
java.whatICanDo();
cpp.whatICanDo();
다형성의 성질을 90% 활용하려면 아래처럼 작성할 수 있다.
// 사람이 보기에, 활용할 수 있는 모든 프로그래밍 언어를 사용해서 정렬 문제를 풀어라! 라는 것 같다.
for(int i = 0; i < 3 ; i ++){
langs[i].whatICanDo();
langs[i].solve("정렬");
}
더 나아가서, 프로그래밍 언어의 배열 안의 요소들 중 python으로만 문제를 풀고 싶은 상황도 생각할 수 있다.
for(language lang : langs){
if(lang instanceof python)
lang.solve("정렬");
물론 다형성을 활용하는 상황에서 instanceof는 지양되는 부분이 있다.
그럼 어떻게 현재 객체가 python 클래스의 객체임을 알 수 있을까?
이를테면 아래처럼 객체의 변수인 lang을 비교하며 확인하는 방법으로 할 수 있다.
for(language lang : langs){
if(lang.lang == "Python")
lang.solve("정렬");
instanceof를 활용하여 실제 객체를 확인하는 방법과, 객체 안에서 확인하는 방법은 if문 안에서 얻는 정보가 차이 난다.
instanceof를 활용할 경우, 해당 객체가 무엇인지 까지 알게 되고, 그를 python클래스의 객체인지 비교하게 된다.
객체 내의 변수나 함수를 활용할 경우 단지 객체 내에서 알 수 있는 정보로 그 객체를 식별한 효과를 얻는다.
위의 출력은 각각 아래와 같다.
//python
ai, data analysis
python은 그냥 하면 된다.
list.sort()
//java
web, app
list.sort((a1,a2)->{
if(a1[0]==a2[0])
return a1[1] - a2[1];
return a1[0]-a2[0];
//c++
simulation
sort(vector.begin(), vector.end(), compare)
bool compare(vector<int> a, vector<int> b) {
if (a[0] == b[0]) return a[1] < b[1];
return a[0] < b[0];
}
(python의 sort는 정말 편해 보인다.)
인터페이스와 그 구현체로 알아보는 오버라이딩
상속관계에 있는 클래스들을 직접 작성해서 다형성이 무엇인지 알아봤는데, 예제로 공부하다 보면 이게 실제로 어떻게 쓰이고 있는지 헷갈릴 때가 있다.
아래에서 프로그래밍을 할 때 쉽게 만날 수 있는 상황을 예로 들어 인터페이스와 구현체가 제공하는 다형성에 대해 알아보겠다.
import java.util.Collections;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
public class list {
List<Integer> linkedList = new LinkedList<>();
List<Integer> arrayList = new ArrayList<>();
public void doSomething(){
//같은 add함수로 3번째 인덱스에 1을 추가하지만,
linkedList.add(3, 1); // 앞뒤 포인터를 연결하는 내부 로직
arrayList.add(3, 1); // 인접한 메모리에 다음 비워져있는 공간에 1을 넣어 인덱스로 접근 가능하도록함
}
}
개발자는 내가 작성하고 있는 linkedList, arrayList 두 변수 모두 List라는 참조 형식이므로, list의 성질을 공통적으로 갖고 있을 것이라고 생각하게 된다. 자연스럽게 add함수가 존재할 것이라는 것도 안다.
아래는 List 참조 형식 (인터페이스) 내에 구현 요구가 되어있는 add 인터페이스 메서드이다.
실제로 이 add 함수가 구현된 부분은 Linkedlist와 ArrayList 클래스 내에 있으며,
각 변수가 실제로 가리키는 변수인 LinkedList add 함수가 내부적으로 어떻게 돌아가는지 개발자는 몰라도 되지만, (사실은 알아야 하지만요)
과거에 어떤 개발자가 클래스 내에 적절한 로직으로 add함수를 구현해 놓았으리라 짐작할 수 있게 하는 효과가 있다.
아래에서 실제 add함수의 구현이 어떻게 되어있는지 가볍게 확인하고 넘어가자.
LinkedList의 add 함수의 구현부
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
연결 리스트는, 현재 인덱스가 어디인지 체크하고, 중간에 link를 연결할 것인지, 맨 뒤에 연결할 것인지 선택하여 실행된다.
ArrayList의 add함수의 구현부
private void add(E e, Object[] elementData, int s) {
if (s == elementData.length)
elementData = grow();
elementData[s] = e;
size = s + 1;
}
인덱싱을 지원하는 ArrayList의 경우 현재 배열의 크기가 전체 크기만큼 할당이 된 경우 크기를 증가시키는 로직과 단순히 배열에 담는 로직이 추가되어 있다.
참고 : 스프링 입문을 위한 자바 객체 지향 원리와 이해 - 김종민