728x90
반응형
SMALL
SMALL

이번에는 굉장히 자주 사용되는 방식 중 하나인 메서드 체이닝에 대해 알아봤다.

대표적인 예시 중 하나가 StringBuilder, QueryDSL같은 라이브러리나 모듈이 이 기법을 사용하고 그 외 여러 라이브러리에서도 많이 사용하는 방법이다. 간단하다.

 

다음 코드를 보자.

Value

public class Value {
    private int value;

    public Value(int value) {
        this.value = value;
    }

    public Value add(int value) {
        this.value += value;
        return this;
    }

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }
}

 

항상 만들어왔던 클래스처럼 생겼는데 add() 메서드를 보자. 반환 타입이 같은 클래스 타입이다. 그리고 자기 자신을 반환하고 있다.

이렇게 만들어 놓으면 메서드 체이닝이 가능해진다. 직접 사용해보자.

 

Main

public class Main {
    public static void main(String[] args) {

        Value data = new Value(10);

        data.add(1).add(2).add(7);

        System.out.println("data = " + data.getValue());
    }
}

위 코드에서 이 부분을 자세히 봐야한다. 

data.add(1).add(2).add(7);

메서드에 메서드를 계속 추가하고 있다. 이게 가능한 이유는 add() 메서드가 반환하는 것이 자기 자신이기 때문이다. 그리고 이 코드의 실행결과는 다음과 같다.

 

실행 결과:

data = 20

 

이렇게 메서드를 계속 이어가서 사용하는 방식을 메서드 체이닝이라고 한다.

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

Class 클래스  (0) 2024.04.03
Wrapper Class  (0) 2024.04.02
String 클래스  (0) 2024.04.01
불변객체 (Immutable Object)  (2) 2024.04.01
java.lang 패키지와 그 첫번째 - Object  (0) 2024.03.31
728x90
반응형
SMALL

불변객체의 가장 대표적인 클래스인 String. 자바에서 문자열을 다룰 때 사용하곤 한다.

자바에서 문자를 다루는 대표적인 타입은 char, String 2가지가 있다.

 public class CharArrayMain {
     public static void main(String[] args) {
         char[] charArr = new char[]{'h', 'e', 'l', 'l', 'o'};
         System.out.println(charArr);
         String str = "hello";
         System.out.println("str = " + str);
     }
}

실행결과:

hello
str = hello

 

보는 것과 같이 char 타입은 한 글자씩만 사용할 수 있기 때문에 문자열로 적합하지 않다. 그래서 String 클래스를 사용하는데, 이 String 클래스를 사용해서 문자열을 생성하는 방법은 2가지가 있다.

  • 쌍따옴표 사용: "hello"
  • 객체 생성: new String("hello");

그러나 String은 클래스고, 참조값을 가져야 하는데 이 코드는 뭔가 이상하다.

String str = "hello";

이는 자바가 문자열 다루는 것 자체가 너무 자주 사용되니까 편의상 허용해준 방법이다. 즉, 저렇게 인스턴스로 직접 만들지 않고 리터럴로 대입을 해도 허용하도록 개발자들에게 편의를 제공한다.

 

 

String 클래스 구조

String 클래스는 클래스이므로 속성과 기능을 가진다. 그리고 내부적으로 다음과 같은 속성을 가지고 있다.

private final char[] value; // 자바 9 이전
private final byte[] value; // 자바 9 이후

 

그러니까 결국 String 클래스도 내부적으로 char 타입의 배열로 문자열을 보관한다는 의미다.

 

근데 자바 9 이전과 이후에 왜 타입이 달라질까? 자바에서 문자 하나를 표현하는 char는 2byte를 차지한다. 그런데 영어, 숫자는 보통 1byte로 표현이 가능하다. 그래서 단순 영어, 숫자로만 표현된 경우 1byte를 사용하고(정확히는 Latin-1 인코딩인 경우 1byte 사용) 그렇지 않은 나머지의 경우 2byte인 UTF-16 인코딩을 사용한다. 따라서 메모리를 더 효율적으로 사용할 수 있게 변경된 것.

 

기능(메서드)은 엄청 많다. 대표적인 기능으로는 다음과 같은 것들이 있다.

  • length()
  • charAt(int index)
  • substring(int beginIndex, int endIndex)
  • indexOf(String str)
  • toLowerCase(), toUpperCase()

그리고 이렇듯 String은 클래스이기 때문에 기본형이 아닌 참조형이다. 참조형은 변수에 참조값(메모리 주소)가 들어간다. 그 말은 연산이 불가능하단 말이고 "+"와 같은 연산을 사용할 수 없는게 원칙적이나 자바에서는 문자열이 너무 자주 다루어지다 보니 편의상 특별히 "+" 연산을 제공한다.

 

String 클래스 비교

결론부터 말하면 String 클래스를 비교할 땐 반드시 equals() 메서드를 사용해야 한다.

근데, 신기한점이 있다. 다음 코드를 보자.

 

public class Main {
    public static void main(String[] args) {
        String a = "hello";
        String b = "hello";

        System.out.println(a == b);
    }
}

이 실행결과는 어떻게 될까?

실행결과:

true

 

이상하다. '=='은 동일성 비교를 할 때 사용하는 연산자이다. 즉, 완전히 같아야 한다는 소린데 String은 클래스고 클래스라는 것은 참조형이며 참조형이면 각자 가지고 있는 메모리가 다를 것이다. 그런데도 불구하고 참이 나온다. 실제로 아래 코드를 수행해보면 기존에 알던 지식 그대로의 결과가 나온다.

public class Main {
    public static void main(String[] args) {
        String a = new String("hello");
        String b = new String("hello");

        System.out.println(a == b);
    }
}

실행결과:

false

 

당연히 위 코드는 납득이 된다. a와 b는 각자 서로 다른 인스턴스이고 그 말은 서로 다른 참조값을 가지니까 동일성 비교('==')는 당연히 다르다. 근데 인스턴스를 새로 만들어서 값을 저장하는게 아닌 리터럴로 저장할 때는 참이 나오는데는 이유가 있다. 

 

자바는 실행되는 시점에 클래스에 String a = "hello";와 같은 문자열 리터럴이 있으면 문자열 풀에 String 인스턴스를 미리 만들어둔다. 이때 같은 문자열이 있으면 만들지 않는다. 그래서 String a = "hello";String b = "hello";는 결국 같은 참조값을 가지고 있게 된다. 즉, 자바가 문자열 풀을 만들어두고 미리 이런 문자열 리터럴을 보관해주는 덕분에 메모리 사용량을 줄이고 성능도 최적화를 해준다.

 

그럼, 여기서 질문이 생긴다. 문자열 리터럴을 사용하는 경우에는 비교 시 '=='를 사용하고 인스턴스를 직접 생성해서 사용하는 경우에만 equals()를 사용하면 될까? 아니다. 무조건 equals()를 사용해야 한다.

 

그 이유는 우선, 코드가 길어지면 질수록 이 코드가 문자열 리터럴로 만들어진것인지 아닌지 알 턱이 없고, 만들어내는 개발자와 비교 로직을 작성하는 개발자가 다르면 더더욱 알 턱이 없어진다. 그렇기 때문에 반드시 문자열은 equals() 비교를 해야한다.

 

불변객체 String

String은 불변객체의 아주 대표적인 예이다. 한번 직접 확인해봐도 좋다. String은 setter도 없고 속성도 final로 선언되어 있다. 

 

"어? 저는 지금까지 잘 바꿔왔는데요?" 그건 기존값을 변경한 게 아니고 새 참조값을 넣었을 뿐이다. 이 질문은 가령 이런 내용일 것이다.

String a = "hello";
a = "hello java";

이건 변수 a의 값을 "hello"에서 "hello java"로 변경한 게 아니고 "hello"라는 값을 담은 인스턴스를 문자열 풀에 자바가 만들어 둔 것을 그대로 둔 상태에서 "hello java"라는 값을 담은 새로운 인스턴스의 참조값을 a에 대입한 것이다. 

 

그리고 저번 포스팅에서 다룬 불변객체가 값을 바꿀 땐 새로운 불변객체를 만들어낸다고 했는데 그것을 String 클래스에서도 확인할 수 있다. 다음 코드를 보자. 문자열을 합치는 concat() 메서드를 사용했지만 다음 코드는 전혀 값의 변화가 없다.

실행결과:

hello

 

기존에 있는 값에 저 문자열을 추가한게 아니라 추가한 문자열을 가지는 새로운 문자열을 만들어내는 불변객체이기 때문이다. 그래서 저 문자열을 추가한 결과를 찍고 싶으면 반환값을 받아야한다.

실행결과:

hello
hellohello java

 

String이 불변객체인 이유

왜 String이 불변객체일까? 문자열 풀에 있는 String 인스턴스의 값이 중간에 변경되면 같은 문자열을 참고하는 다른 변수의 값도 함께 변경되기 때문이다. 그러니까 만약에 다음 코드를 보면,

String a = "hello";
a = "hello java";

 

String a = "hello";(x001)를 자바가 보고 문자열 풀에 "hello"라는 문자열을 담은 인스턴스를 딱 한개만 보관한다. 그래서 다른 곳에서 같은 "hello"라는 문자열을 사용할 때 같은 참조값(x001)을 돌려주게 되어있다.

 

그런데 저 코드가 "hello"라는 문자열을 담은 인스턴스를 "hello java"를 담는것으로 변경해버리는 거라면 다른곳에서 참조하는 "hello"도 "hello java"로 변경된다는 것이다. 그러면 사이드 이펙트가 발생할 수 있다. 그래서 그게 아니라 "hello java"라는 문자열을 담는 새로운 인스턴스 참조값을 변수 a에 대입하는 것이다.

 

불변객체 String의 단점과 StringBuilder

String이 불변객체이고 불변객체인 이유까지 알았다. 근데 불변객체라고 장점만 있는것은 아니다. 어떤 단점이 있을까? 예를 들어 다음과 같은 코드가 있다고 가정해보자.

String str = "A" + "B" + "C" + "D";

 

아주 단순하게 A, B, C, D라는 문자를 더하는 것 같지만 이 연산을 수행하기 위해 다음과 같은 절차를 거쳐야한다.

1. A, B, C, D를 각각 담는 4개의 String 인스턴스를 만든다.

2. String은 불변객체이므로 "A" + "B"를 하기 위해 "AB"라는 새로운 String 인스턴스를 만든다.

3. String은 불변객체이므로 "AB" + "C"를 하기 위해 "ABC"라는 새로운 String 인스턴스를 만든다.

4. String은 불변객체이므로 "ABC" + "D"를 하기 위해 "ABCD"라는 새로운 String 인스턴스를 만든다.

 

저 한줄의 연산을 위해 3번의 새로운 인스턴스를 만든다. 얼마나 비효율적인가? 그럼 더 나아가서 문자열이 길어지면 길어질수록 더더더 많은 인스턴스를 만들 수 밖에 없다. 이것이 String이라는 불변객체의 단점이다. (물론, 원리는 이게 맞지만 자바가 최적화를 한다)

 

그래서 이런 문제를 해결하려면 ? 불변객체가 아닌 가변객체를 사용하면 된다.

StringBuilder

자바가 StringBuilder라는 가변 String을 제공해준다. "어 정말 가변인가요?" StringBuilder 클래스에 들어가보면 String과는 달리 final 키워드가 없는 것을 확인해볼 수 있다.

 

그럼 StringBuilder를 어떻게 사용하는지 확인해보자. 

public class SbMain {
    public static void main(String[] args) {
        StringBuilder builder = new StringBuilder();

        builder.append("A");
        builder.append("B");
        builder.append("C");
        builder.append("D");
        System.out.println("builder = " + builder);

        builder.insert(4, "JAVA");
        System.out.println("builder = " + builder);

        builder.delete(4, 8);
        System.out.println("builder = " + builder);

        builder.reverse();
        System.out.println("builder = " + builder);

        String string = builder.toString();
        System.out.println("string = " + string);
    }
}

실행결과:

builder = ABCD
builder = ABCDJAVA
builder = ABCD
builder = DCBA
string = DCBA

StringBuilder 객체를 생성한 후, 해당 객체에 append() 메서드를 사용해서 문자열을 뒤로 계속하여 추가할 수 있다. 보면 알겠지만 append() 메서드의 반환값을 받아온 게 아니고 그대로 builder를 사용해서 진행하고 있다. 즉, 불변객체가 아니라는 소리다.

 

insert()는 특정 위치에 문자열을 추가한다.

delete()는 특정 범위의 문자열을 삭제한다.

reverse()는 문자열을 뒤집는다.

 

toString()은 String 객체를 만들어낸다. 불변객체를 만들어낸다는 의미이고 StringBuilder로 작업을하고 모든 작업이 끝나면 사이드 이펙트를 방지하기 위해 불변객체로 변환하는것도 좋은 습관이 될 수 있다.

 

 

String 최적화

다음 코드를 얘기하면서 불변객체가 주는 비효율성을 얘기했다.

String str = "A" + "B" + "C" + "D";

그리고 이 작업 원리에 대해 자바가 최적화를 해준다고도 했다. 어떤 최적화를 해주냐면 자바가 컴파일 시에 다음 코드를 보고 그냥 저 문자를 합쳐버리는 행위를 한다. 다음과 같이.

String str = "ABCD";

즉, 런타임 시에 연산 작업을 하지 않게 자바가 컴파일 하면서 최적화를 해준다.

 

그러나 저건 그냥 문자열이니까 가능한데 변수에 담긴 문자열은 어떻게 될까?

String str = str1 + str2;

다음 코드 같은 경우에는 어떤 값이 들어있는지 컴파일 시점에는 알 수 없기 때문에 단순하게 합칠 수 없다. 그래서 다음과 같이 비슷하게 최적화를 해준다.

String str = new StringBuilder().append(str1).append(str2).toString();

그러니까 최대한 최적화할 수 있는 만큼은 해준다는거고 이렇게 자바가 알아서 다 해주기 때문에 개발자는 "어.. 이거를 최적화해야겠다"하고 StringBuilder를 사용해서 String str = StringBuilder().append(str1).append(str2).toString() 이렇게 직접 작성안해도 된다. 그냥 + 연산을 하면 된다. 알아서 자바가 해준다.

 

근데, 그럼에도 String 최적화가 어려운 경우가 있다. 그래서 개발자가 직접 최적화해야 하는 경우가 있다. 어떤 경우냐면, 루프안에서 문자열을 더하는 경우이다.

public class Main {
    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();

        String result = "";

        for (int i = 0; i < 100000; i++) {
            result += "Hello Java";
        }

        long endTime = System.currentTimeMillis();

        System.out.println(result);
        System.out.println("time = " + (endTime - startTime) + "ms");
    }
}

 

이런 경우 자바가 어떤 작업을 하냐면 대략 이런 작업을 한다. 

String result = "";
 for (int i = 0; i < 100000; i++) {
     result = new StringBuilder().append(result).append("Hello Java
 ").toString();
}

근데, 이게 최적화가 되는것 같아도 그게 아니다. 오히려 더 성능을 악화시킨다. 왜냐하면, 반복 횟수만큼 StringBuilder 객체를 생성하고 있다. 거기다가 반복횟수만큼 String 객체도 생성한다. 이게 얼마나 오래 걸릴지 실행해보자.

....
JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello Java
time = 7142ms

7초가 걸렸다. 고작 100,000번의 반복으로 7초가 걸리는건 정말 문제가 있는거다. 이럴땐 개발자가 직접 최적화하는게 훨씬 효율적이다.

다음 코드가 개발자가 직접 최적화한 경우이다.

public class StringBuilderMain {
    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();

        StringBuilder stringBuilder = new StringBuilder();

        for (int i = 0; i < 100000; i++) {
            stringBuilder.append("Hello Java");
        }

        long endTime = System.currentTimeMillis();

        String result = stringBuilder.toString();
        System.out.println(result);
        System.out.println("time = " + (endTime - startTime) + "ms");
    }
}

StringBuilder 객체를 딱 한번만 만들고, String 객체도 딱 한번만 만든다. 이 코드는 얼마나 걸릴까? 

...
JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello JavaHello Java
time = 10ms

1초도 안 걸린다. 700배 정도 차이가 있는거다. 어마어마한 차이다. 이렇듯 개발자가 직접 최적화를 해야하는 경우도 있고 단순한 경우 자바가 알아서 최적화를 해주기 때문에 고려를 하지 않아도 되는 경우가 있다.

 

그럼 StringBuilder를 직접 사용하는 경우가 더 좋은 경우는 다음과 같은 경우가 있다고 정리하고 마치면 될듯하다.

  • 반복문에서 반복해서 문자를 연결할 때
  • 조건문을 통해 동적으로 문자열을 조합할 때
  • 복잡한 문자열의 특정 부분을 변경해야 할 때
  • 매우 긴 대용량 문자열을 다룰 때

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

Wrapper Class  (0) 2024.04.02
Method Chaining  (0) 2024.04.02
불변객체 (Immutable Object)  (2) 2024.04.01
java.lang 패키지와 그 첫번째 - Object  (0) 2024.03.31
OCP (Open-Closed Principle) 원칙  (0) 2024.03.30
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

불변객체가 필요한 이유

불변객체에 대해서 완전히 제대로 알아보자. 불변객체가 뭔지 알기 전에 불변 객체가 왜 필요한 지부터 알아야 한다. 자바에서 가장 크게 변수는 두 가지 타입이 있다.

  • 기본형 (Primitive type)
  • 참조형 (Reference type)

기본형은 값의 공유가 절대로 일어나지 않는다. 즉, 특정값을 어떤 기본형 변수에 넣으면 변수끼리 그 값을 공유할 수 있는 방법은 없다.

언제나 자바에서 대입은? 값을 복사해서 대입한다.

 

다음 코드를 보자. a라는 변수에 10을 담고 b라는 변수에 a를 대입했다. a와 b가 값을 공유하나? 아니다. 값을 복사해서 b에 넣어준 것뿐이다. 실제로 b를 변경해도 a에는 아무런 영향이 없다.

int a = 10;
int b = a;

 

정말로 그런지 확인해보자.

실행 결과:

a = 10
b = 10
a = 10
b = 20

 

b를 변경해도 a에는 아무런 영향이 없다.

 

근데, 참조형은 어떨까? 참조형은 참조값을 복사해서 대입한다. 코드로 보자.

 

Value

public class Value {
    private int value;

    public Value(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }

    @Override
    public String toString() {
        return "Value{" +
                "value=" + value +
                '}';
    }
}

 

Main

public class Main {
    public static void main(String[] args) {
        Value v1 = new Value(3);
        Value v2 = v1;

        System.out.println(v1.getValue());
        System.out.println(v2.getValue());

        v2.setValue(10);
        System.out.println(v1.getValue());
        System.out.println(v2.getValue());
    }
}

 

Value라는 클래스의 인스턴스를 v1, v2로 만들었다. v2는 v1을 대입한다. 참조형은 참조값을 복사해서 대입한다. 즉, v1과 v2는 같은 메모리 주소를 공유한다. 이 상태에서 둘 중 하나의 값을 바꿔버리면 나머지 하나도 변경되는 상황이 생겨난다.

 

실행결과:

3
3
10
10

 

이걸 의도해서 만든 거라면 아무런 문제가 없지만, 그게 아니라면? 문제가 생기는 것이다. 특정 개발자는 3이라는 값을 가진 Value 인스턴스를 그저 복사해서 같은 3이라는 값을 가지는 새로운 인스턴스를 원했던 것인데 하나를 바꾸니 나머지 하나도 변경이 되는 것이다.

 

이걸 해결하는 근본적인 방법은 "새로운 인스턴스를 만드는 것이다". 다음이 그 예시이다.

public class Main {
    public static void main(String[] args) {
        Value v1 = new Value(3);
        Value v2 = new Value(5);

        System.out.println(v1.getValue());
        System.out.println(v2.getValue());

        v2.setValue(10);
        System.out.println(v1.getValue());
        System.out.println(v2.getValue());
    }
}

 

실행결과:

3
5
3
10

 

이제 v2값을 변경해도 v1에는 영향이 끼치지 않는다. 좋다. 근데 남아 있는 문제는 결국엔 두 객체 간 참조값 공유를 막을 방법은 없다는 것이다. 어떤 개발자가 다음과 같은 코드를 작성했을 때 이를 막아줄 방법이 없다. 왜냐면 문법적으로 잘못된 게 없으니까. 

Value v1 = new Value(3);
Value v2 = v1;

 

그러니까 결국 위와 같은 문제를 방지하려면 개발자가 알아서 이런 경우를 만들지 않도록 코드를 짜야하는데 가장 멀리해야 하는 게 스스로를 신뢰하는 것 아닌가? 저렇게 코드를 안 짜리라고 보장할 수 없다. 이걸 해결하기 위해 불변객체가 등장(?)한 것.

 

 

불변객체 (Immutable Object)

위 문제를 해결하기 위해 불변 객체라는 것이 사용된다. 불변 객체는 객체의 상태(객체 내부의 값, 필드, 멤버 변수)가 변하지 않는 객체를 불변 객체라고 한다. 사실 그렇다. 위 문제에서 참조값을 공유하는 것을 막을 수 있는 방법은 없다. 문법적으로도 잘못된 게 아니다. 그러면 근본적인 원인은 해결할 수가 없고, 대안을 찾아야 하는데 결국 문제가 발생하는 지점은 두 객체가 가지고 있는 값을 한 쪽에서 변경할 때 발생하지 않는가? 그럼 그 값을 변경하지 못하도록 막아버리는 방법이 있다. 그리고 이게 불변 객체이다.

 

그리고 실제로 참조값 공유는 유용하다. 만약 같은 값 3을 가지는 Value 인스턴스가 정말 필요하다면 굳이 새로운 인스턴스를 만들어서 괜히 메모리를 더 쓰는게 아니라 이미 가지고 있는 인스턴스를 공유하면 더 효율적이기 때문이다. 그러니 참조값 공유를 막는것을 생각하지 말고 객체가 가지는 상태를 변경하는 것을 막는것으로 바꾸는 것이다.

 

방법은 꽤나 간단하다. 필드를 final로 선언하고 setter를 빼버리면 된다.

public class Value {
    private final int value;

    public Value(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }

    @Override
    public String toString() {
        return "Value{" +
                "value=" + value +
                '}';
    }
}

 

이렇게 불변 객체로 만들면 한번 값이 적용된 후 값을 바꾸려고 하면 컴파일 에러가 발생한다.

 

위와 같은 에러가 발생하면 개발자는 "어? 하고 어떤 에러가 났는지 볼 것이고 보니까 아 이거 불변 객체구나! 값을 바꾸려면 새로 인스턴스를 만들어야겠다." 라는 생각을 할 수 있게 만들어 주는것.

 

 

불변객체 예시 코드

그럼 예시를 한번 만들어보자.

Member

public class Member {
    private String name;
    private Address address;

    public Member(String name, Address address) {
        this.name = name;
        this.address = address;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Address getAddress() {
        return address;
    }

    public void setAddress(Address address) {
        this.address = address;
    }

    @Override
    public String toString() {
        return "Member{" +
                "name='" + name + '\'' +
                ", address=" + address +
                '}';
    }
}

Address

public class Address {
    private String value;

    public Address(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }

    public void setValue(String value) {
        this.value = value;
    }

    @Override
    public String toString() {
        return "Address{" +
                "value='" + value + '\'' +
                '}';
    }
}

 

이 예시는 Member 클래스와 Address 클래스가 있을 때, Member 클래스에는 name, address라는 필드가 있다. 그래서 이 두개의 클래스를 가지고 다음과 같이 실행 코드를 만들었다고 해보자.

Main

public class Main {
    public static void main(String[] args) {
        Address address = new Address("서울");
        
        Member m1 = new Member("A", address);
        Member m2 = new Member("B", address);
    }
}

 

주소가 서울인 회원 두명이 있다. 이렇게 회원이 잘 만들어 졌는데 요구사항이 들어왔다. "회원 B의 주소를 서울에서 부산으로 변경해라."

그럼 개발자는 다음과 같은 행위를 한다.

m2.getAddress().setValue("부산");

 

좋다, 이제 그래서 다음과 같이 변경사항을 출력하는 코드까지 작성하고 실행 결과를 확인했다.

public class Main {
    public static void main(String[] args) {
        Address address = new Address("서울");

        Member m1 = new Member("A", address);
        Member m2 = new Member("B", address);

        System.out.println(m1);
        System.out.println(m2);

        System.out.println("주소 변경---------");

        m2.getAddress().setValue("부산");
        System.out.println(m1);
        System.out.println(m2);
    }
}

 

실행결과:

Member{name='A', address=Address{value='서울'}}
Member{name='B', address=Address{value='서울'}}
주소 변경---------
Member{name='A', address=Address{value='부산'}}
Member{name='B', address=Address{value='부산'}}

 

실행 결과를 보니 원하지 않는 결과가 발생했다. 회원 B의 주소만 변경했는데 회원 A의 주소까지 변경됐다. 

이제 이런 문제를 불변객체를 사용해서 방지해보자. Member는 불변객체가 아니다. Member 클래스의 필드(name, address)는 변경할 수 있어야한다. 

ImmutableAddress

public class ImmutableAddress {
    private final String value;

    public ImmutableAddress(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }
    
    @Override
    public String toString() {
        return "Address{" +
                "value='" + value + '\'' +
                '}';
    }
}

 

그래서 Address 클래스를 불변 객체로 변경했다. 필드에 final을 붙이고 setter를 떼버렸다. 이 클래스로 기존 코드를 대체해보자.

Main

그랬더니 다음과 같이 컴파일 에러가 발생한다. 개발자는 보고 어? 왜 에러가 발생했지?로 시작해서 아! 이거 불변객체라서 이렇게 사용못하는구나!"를 깨닫는다. 그래서 다음과 같은 코드로 대체한다.

그래서 결국 원하는 결과를 도출할 수 있었다.

 

실행결과:

Member{name='A', address=Address{value='서울'}}
Member{name='B', address=Address{value='서울'}}
주소 변경---------
Member{name='A', address=Address{value='서울'}}
Member{name='B', address=Address{value='부산'}}

 

 

불변객체의 값 변경

아무리 불변객체라고 해도 값을 변경하고 싶을때가 있다. 이럴땐 어떻게 하면 될까? 새로운 인스턴스를 반환하면 된다. 다음 코드를 보자.

ImmutableObj

public class ImmutableObj {
    private final int value;

    public ImmutableObj(int value) {
        this.value = value;
    }
    
    public ImmutableObj withValue(int value) {
        return new ImmutableObj(value);
    }

    public int getValue() {
        return value;
    }

    @Override
    public String toString() {
        return "ImmutableObj{" +
                "value=" + value +
                '}';
    }
}

 

불변 객체인 ImmutableObj 클래스가 가진 value라는 필드를 변경하고 싶은 경우 위 코드처럼 withValue()라는 메서드를 하나 만든다.

보통은 이렇게 불변객체에 새로운 값을 넣고 새로운 인스턴스를 반환하는 메서드를 만들 때 관례상 with___() 메서드 명을 따른다. 변경하고자 하는 값을 파라미터로 받아서 새로운 ImmutableObj 객체를 리턴하는 방식이다.

Main

public class Main {
    public static void main(String[] args) {
        ImmutableObj obj = new ImmutableObj(3);
        ImmutableObj obj2 = obj.withValue(10);

        System.out.println(obj);
        System.out.println(obj2);
    }
}

 

그래서 이 코드를 실행해보면 기존 객체는 값이 전혀 변경되지 않고, 새로운 객체를 만들어 새로운 값을 가지는 녀석으로 반환한다.

실행결과:

ImmutableObj{value=3}
ImmutableObj{value=10}
728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

Method Chaining  (0) 2024.04.02
String 클래스  (0) 2024.04.01
java.lang 패키지와 그 첫번째 - Object  (0) 2024.03.31
OCP (Open-Closed Principle) 원칙  (0) 2024.03.30
다형성 (Part.2) 사용하기 ✨  (0) 2024.03.29
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

패키지 중 java.lang이라는 패키지가 있다. 자바가 기본으로 제공하는 라이브러리(클래스 모음) 중에 가장 기본이 되는 것이 바로 java.lang 패키지이다. 여기서 lang은 Language의 줄임말이다. 

 

java.lang 패키지의 대표적인 클래스들

  • Object: 모든 자바 객체의 최상위 부모 클래스
  • String: 문자열
  • Integer, Long, Double: Wrapper 타입, 기본형 데이터 타입을 객체로 만든 것
  • Class: 클래스 메타 정보
  • System: 시스템과 관련된 기본 기능들을 제공

이 클래스들은 너무나 기본이 되는 내용들이고 중요하기 때문에 반드시 잘 알아두어야 한다.

 

import 생략 가능

java.lang 패키지는 모든 자바 애플리케이션에 자동으로 임포트가 된다. 따라서 임포트 구문을 사용하지 않아도 된다.

다음과 같이 임포트 없이 사용할 수 있다.

 

Object 클래스

모든 객체의 최상위에는 항상 Object 클래스가 있다. "어? 저는 Object를 상속받은 클래스가 없는데요?" 자바가 알아서 해준다.

그러니까 extends 키워드로 특정 클래스를 상속받지 않는 클래스는 자바가 암묵적으로 extends Object를 넣어준다.

 

Object 클래스가 필요한 이유

그럼 왜 이 Object 클래스가 필요한 걸까? Object 클래스에는 다음과 같은 대표적인 메서드가 있다.

  • toString()
  • getClass()
  • equals()

이는 객체가 어떤 객체인지 알기 위해 반드시 필요한 기능이고 있어야만 한다. 근데 만약 Object라는 클래스가 없으면 개발자마다 저 기능들을 본인 입맛에 맞게 구현할 텐데 그때마다 다 시그니쳐가 다를 거다. 예를 들어, 객체끼리 서로 같은 객체인지 비교하기 위한 메서드인 equals()라는 메서드를 누군가는 same()이라는 메서드로 만들 수 있을 것이다. 이렇게 반드시 객체라면 필요한 기능을 규칙 없이 개발자마다 달라지는 게 아니라 자바가 딱 하나로 정의를 해두고 그것을 약속하면 서로 다른 개발자들끼리 혼동이 없을 것이다. 이 때문에 Object라는 클래스가 존재한다. 

 

그리고 한가지 더 Object라는 클래스가 존재하는 이유는 객체 지향의 꽃인 다형성의 시작을 내포하기 때문이다. Object는 모든 객체의 결국 최상위 부모이다. 그 말은 어떤 객체를 만들어도 Object라는 타입으로 담을 수 있다는 얘기다. 객체와 메모리 구조를 잘 떠올려보라.

어떤 객체를 새로 만들어도 위 그림과 같이 결국 최상위에는 Object가 있기 때문에 어떤 객체를 만들어도 Object로 업캐스팅이 가능하다. 즉, 시작부터 다형성인 것이다. 따라서 타입이 다른 객체들을 어딘가에 보관해야 한다면 바로 Object에 보관하면 된다.

 

이 말을 코드로써 표현해보자. 예를 들어, 배열이 있다고 생각해 보자. 근데 그 배열에는 여러 타입을 담고 싶은 것이다. 보통의 배열이라면 특정 타입으로 선언해서 해당 타입에 맞는 요소들만 담을 수 있는데 그게 아니고 이 타입 저 타입 모두 한 배열에 담고 싶은 요구사항이 있는 것이다. 이때 사용할 수 있는 게 Object인 것이다.

 

다음 코드를 보자. 전혀 다른 타입의 요소들이 하나의 배열에 들어가 있다. 이것이 바로 Object라는 클래스가 주는 다형성이다.

 

어떤 타입이던 Object는 그 상위에 있는 부모이기 때문에 담을 수가 있다. 만약, Object가 없다면 이러한 행위가 불가능하다. 물론, MyObject라는 클래스를 직접 개발자가 정의해서 모든 클래스마다 이 MyObject를 상속받으면 담을 수 있겠지만 그건 그 코드한정이다. 다른 개발자가 만든 코드에선 절대 호환되지 않을 것이다. 다른 개발자가 만든 코드에는 MyObject가 없을 테니까. 이게 Object 클래스가 있는 이유이다.

 

Object와 OCP

OCP 원칙을 기억하는가? Open-Closed Principle 즉, 확장에는 열려있고 변경에는 닫혀있는 이 원칙이 Object에도 이미 적용되어 있다. 이 원칙에 가장 대표적인 예시가 System.out.println()Object이다. 우리가 알고 그냥 썼던 System.out.println()은 파라미터로 Object 타입을 받는다. 그리고 실제 그 내부 코드를 들어가 보면 파라미터로 받는 ObjecttoString() 메서드를 호출한다는 사실을 아는가?

 

System.out.println()은 다형성의 끝판왕이다.

 

Object라는 가장 상위 부모인 클래스를 파라미터로 받아 다형적 참조가 가능하게 했고, toString()이라는 메서드를 호출할 때 어떤 인스턴스가 들어오던 toString()을 오버라이딩 했다면 그 오버라이딩한 메서드를 호출하고 그게 아니라면 부모가 가지고 있는 toString()이라는 메서드를 호출해서 메서드 오버라이딩 기능을 제대로 사용하고 있는 것이다.

 

그리고 그 결과 아무리 많은 클래스를 만들어도 그 클래스의 부모는 Object이기 때문에 확장에 무한히 열려있다(Open). 그리고 이렇게 확장을 원하는 대로 하더라도 사용하는 클라이언트 코드인 System.out.println()에는 어떠한 변경도 필요가 없다(Closed). 

 

완벽한 OCP 원칙이라고 볼 수 있다.

 

 

equals()

Object는 동등성 비교를 위한 equals() 메서드를 제공한다.

 

자바는 두 객체가 같다는 표현을 2가지로 분리해서 제공한다.

  • 동일성(Identity): == 연산자를 사용해서 두 객체의 참조가 동일한 객체를 가리키고 있는지 확인
  • 동등성(Equality): equals() 메서드를 사용해서 두 객체가 논리적으로 동등한 지 확인

동일은 완전히 같음을 의미한다. 반면, 동등은 같은 가치나 수준을 의미하지만 그 형태나 외관 등이 완전히 같지는 않을 수 있다.

쉽게 이야기해서 동일성은 물리적으로 같은 메모리에 있는 객체 인스턴스인지 참조값을 확인하는 것이고, 동등성은 논리적으로 같은지 확인하는 것이다. 동일성은 자바 머신 기준이고 메모리의 참조가 기준이므로 물리적이다. 반면 동등성은 보통 사람이 생각하는 논리적인 기준에 맞추어 비교한다. 

 

예를 들어, 같은 회원 번호를 가진 회원 객체가 2개 있다고 가정해 보자.

User user1 = new User("id-100");
User user2 = new User("id-100");

 

이 경우, 물리적으로 다른 메모리에 있는 다른 객체이지만, 회원 번호를 기준으로 생각해 보면 논리적으로는 같은 회원으로 볼 수 있다.

그 말은 동일성은 다르지만 동등성은 같다. 

 

문자의 경우도 생각해 보자.

String s1 = "hello";
String s2 = "hello";

이 경우 물리적으로는 각각의 "hello" 문자열이 다른 메모리에 존재할 수 있지만, 논리적으로는 같은 "hello"라는 문자열이다.

(사실 이 경우 자바가 같은 메모리를 사용하도록 최적화한다. 그래서 == 비교를 해도 '참'을 반환한다.)

 

동일성과 동등성 비교

예제를 통해서 동일성과 동등성을 비교해 보자.

 

어떤 결과가 나올 것 같은가? 우선 '==' 연산자를 사용한 경우 동일성, 즉 같은 메모리에 들어있는가?를 묻고 있는 것이기 때문에 거짓이 될 것이다. 그럼 동등성을 비교하는 equals()를 사용했을 때 두 User 객체가 같은 ID를 사용하므로 참을 반환할까? 결과는 다음과 같다.

 

실행결과:

Identity = false
Equality = false

 

'==' 연산자는 납득이 되는데 equals()를 사용했을 때도 false가 나왔다. 이 이유는 기본적으로 Object 클래스에 있는 equals() 메서드는 다음과 같이 생겼다. 즉, 똑같이 '==' 비교를 하고 있다.

 

엇? 왜 이럴까? 생각해 보면 모든 클래스는 다 동등성을 비교하는 기준이 다를 것이다. 어떤 클래스는 ID로, 어떤 클래스는 Name으로, 어떤 클래스는 그 두 개를 동시에 비교해서 같은지를 판단할 것이다. 즉, 누가 어떻게 어떤 의도로 만들었냐에 따라 동등성은 다 달라질 것이다라는 말이다. 그걸 Object라는 하나의 클래스가 모든 케이스를 다 정의할 수 없다. 그래서, 이 equals() 메서드를 사용자가 재정의(오버라이딩) 해야 한다. 그렇지 않으면 기본으로 Object 클래스는 eqauls() 메서드 내부에서 동일성 비교를 한다.

 

그럼 직접 equals() 메서드를 오버라이딩 해보자. 근데, 그럴 필요가 없다. 왜냐? IDE에서 잘 알아서 해준다. 그래서 우린 IDE의 도움을 받으면 된다. 어떻게 하냐? 맥 기준 커맨드 + N 을 눌러보면 다음과 같이 나온다.

여기어 보이는것과 같이 equals() and hashCode()를 선택하면 된다. 그럼 여러 선택 팝업이 나오는데 일단 이 부분은 Next를 클릭한다.

 

그리고 그 다음 나오는 부분이 중요하다면 중요할 수 있는데, 어떤 필드를 기준으로 동등성을 체크할지를 묻는 팝업이다. 지금은 id가 같으면 동등하다고 볼 것이므로 다음과 같이 ID를 체크하고 넘어가면 된다.

 

그래서 딱히 변경할 것 없이 다 Next 하고 Create 하면 다음과 같이 이쁜 equals()를 만들어준다.

 

코드를 하나씩 뜯어보자.

1. 처음에 '==' 비교가 있다. 즉, 참조값 자체가 같으면 동등을 떠나 동일하다는 거니까 바로 참을 반환한다.

2. 어떤 객체가 null인 경우 반드시 그건 동일하지 않아야 하며, 혹여나 현재 클래스와 다른 인스턴스 타입이라면 그것도 또한 동일하지 않다. 그래서 그 조건 중 하나라도 참이라면 거짓을 반환한다. 

3. 위 조건을 만족해야만 현재 클래스로 다운 캐스팅이 가능하다.

4. 체크한 id를 기반으로 this.id와 파라미터로 넘겨받은 객체의 id가 같다면 참, 다르다면 거짓을 반환한다.

 

 

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

String 클래스  (0) 2024.04.01
불변객체 (Immutable Object)  (2) 2024.04.01
OCP (Open-Closed Principle) 원칙  (0) 2024.03.30
다형성 (Part.2) 사용하기 ✨  (0) 2024.03.29
다형성 (Part.1) 매우중요 ✨  (0) 2024.03.28
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

좋은 객체 지향 설계 원칙 중 하나로 OCP 원칙이라는 것이 있다.

  • Open for extension: 새로운 기능의 추가나 변경 사항이 생겼을 때, 기존 코드는 확장할 수 있어야 한다.
  • Closed for modification: 기존의 코드는 수정되지 않아야 한다.

확장에는 열려있고, 변경에는 닫혀 있다는 뜻인데, 쉽게 이야기해서 기존의 코드 수정 없이 새로운 기능을 추가할 수 있다는 의미다. 인터페이스(또는 순수 추상 클래스)와 다형성을 이용해서 새로운 구현 클래스가 계속 늘어나더라도 그 인터페이스를 사용하는 클라이언트는 구현 클래스가 늘어난 사실조차 몰라도 아무런 코드의 변경없이 원래 의도대로 동작하는 것을 말한다. 

 

예를 들어, 다음 그림을 보자.

Driver라는 클래스는 인터페이스 Car를 의존한다. 여기서 의존이라는 말은 알고 있다는 사실로 봐도 무방하다. 그래서 Car라는 인터페이스가 가지고 있는 메서드 startEngine(), offEngine(), pressAccelerator() 이 세가지 메서드를 사용한다. 그리고 실제 Car라는 인터페이스를 구현하는 여러 구현 클래스가 있을 것이다. 그 중 K3, Model 3, Model Y, Genesis 등 여러 구현 클래스가 계속해서 늘어나더라도 Driver라는 클라이언트 입장에서는 늘어났다는 사실조차 몰라도 아무런 코드의 변경이 없이 기존 코드 그대로 동작가능하게 설계하는 것이 Open-Closed Principle이다.

 

코드로 하나하나 이해해보자.

 

Car

다음은 인터페이스 Car 코드이다. 이 Car라는 인터페이스는 3개의 메서드를 가지고 있다. 이제 Car를 구현하는 클래스는 반드시 이 세개의 메서드를 오버라이딩해야 한다.

public interface Car {

    void startEngine();
    void offEngine();
    void pressAccelerator();
}

 

K3

다음은 K3 클래스이다. Car를 구현하게 설계했으므로 반드시 오버라이딩 해야하는 3개의 메서드를 전부 오버라이딩한다.

public class K3 implements Car {

    @Override
    public void startEngine() {
        System.out.println("K3 시동 켜기");
    }

    @Override
    public void offEngine() {
        System.out.println("K3 시동 끄기");
    }

    @Override
    public void pressAccelerator() {
        System.out.println("K3 엑셀 밟기");
    }
}

 

Model 3

다음은 Model 3 클래스이다. K3와 마찬가지이다.

public class Model3 implements Car {

    @Override
    public void startEngine() {
        System.out.println("Model 3 시동 켜기");
    }

    @Override
    public void offEngine() {
        System.out.println("Model 3 시동 끄기");
    }

    @Override
    public void pressAccelerator() {
        System.out.println("Model 3 엑셀 밟기");
    }
}

 

Driver

Driver 클래스는 Car라는 인터페이스를 사용하는 클라이언트 입장이다. Car라는 인터페이스를 구현하는 클래스가 계속해서 늘어나더라도 이 코드의 변경사항은 없다. 즉, 여기서 바로 OCP원칙이 두각이 드러난다. 아무리 기능이 확장되어도(차종이 늘어나는것) 코드의 변경이 없다. 

public class Driver {

    private Car car;

    public void drive() {
        car.startEngine();
        car.pressAccelerator();
        car.offEngine();
    }

    public void setCar(Car car) {
        this.car = car;
    }

    public Car getCar() {
        return car;
    }
}

 

Main

실행 코드에서 확인해보자. 일단 K3를 Driver 클래스가 사용한다. K3는 Car라는 인터페이스를 구현하기 때문에 Driver의 메서드 setCar()에 파라미터로 K3를 넘겨줄 수 있다. 부모는 자식을 허용하기 때문에. 정확히는 객체와 메모리 구조를 생각해보면 K3라는 인스턴스는 참조 공간에 부모와 같이 쌓아 올려진다. 그렇기 때문에 부모 타입 변수에 담을 수 있는것이다. (업캐스팅)

public class Main {
    public static void main(String[] args) {
        Driver driver = new Driver();
        K3 k3 = new K3();

        driver.setCar(k3);

        driver.drive();

    }
}

이렇게 해서 실행해보면 다음과 같이 잘 실행된다.

실행결과:

K3 시동 켜기
K3 엑셀 밟기
K3 시동 끄기

 

근데 여기서 Model 3 로 자동차를 바꿔보자. 아예 없던 클래스라고 생각하고 새로 만들었다고 생각해보자. 즉, 기능의 확장이 일어난것이다. 근데 Driver 코드는 변경되지 않는다. 바뀌는 부분은 사용하는 코드만 변경될 뿐이다. 다음 코드처럼.

public class Main {
    public static void main(String[] args) {
        Driver driver = new Driver();

        Model3 model3 = new Model3();
        driver.setCar(model3);
        driver.drive();
    }
}

 

실행결과:

Model 3 시동 켜기
Model 3 엑셀 밟기
Model 3 시동 끄기

 

이것을 OCP 원칙이라고 한다. 그러니까 기존의 코드에 대한 변경이 아예 없을 순 없다. 새로운 기능을 추가하는 것 자체가 기존 코드에 변경이 일어나는데 어떻게 아예 변경을 안하겠는가? 그러나 절대적인 원칙은 지켜져야한다. 클라이언트의 코드는 변경되지 않거나 변경하더라도 최소화해야 한다. 이 코드에서 클라이언트는 누구인가? Driver다. 서버는 누구인가? Car라는 인터페이스다. 클라이언트는 서버만 알면 된다. 그 서버를 실제로 구현한 클래스가 100개든 1000개든 알 필요가 없다. 그리고 클라이언트의 코드는 변경이 필요가 없다. 이게 중요한 것이다. 사용할 때는 당연히 코드 변경이 필요하다. 위에 예시처럼, K3에서 Model3로 바꾸는 그런 과정들. 그리고 이것이 디자인 패턴 중에정말 중요한 하나인 전략 패턴이랑 매우매우 유사한데 전략 패턴을 하나 배웠다고 해도 과언이 아니다. 

728x90
반응형
LIST
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

이제 Part.1 에서 배운  다형성 핵심 개념인 다형적 참조와 메서드 오버라이딩을 가지고 매우 강력한 기능을 사용해보자.

다형성 개념을 사용하기 전 코드를 보고 사용한 코드를 본 다음 얼마나 강력한 것인지를 체감해보자.

 

다형성을 도입하기 전

Dog

public class Dog {
	public void sound() {
    	System.out.println("멍멍");
    }
}

 

Cat

public class Cat {
	public void sound() {
    	System.out.println("냐옹");
    }
}

 

Caw

public class Caw {
	public void sound() {
    	System.out.println("음메");
    }
}

 

Main

public class Main {
	public static void main(String[] args) {
    	Dog dog = new Dog();
        Cat cat = new Cat();
        Caw caw = new Caw();
        
        System.out.println("강아지 소리");
        dog.sound();
        
        System.out.println("고양이 소리");
        cat.sound();
        
        System.out.println("소 소리");
        caw.sound();
    }
}

 

실행결과:

강아지 소리
멍멍
고양이 소리
냐옹
소 소리
음메

 

Main 코드를 보면 벌써 숨이 턱 막힌다. 일단 중복 코드가 발생했다는 부분이 너무 불편하다. 이것은 근데 다형성으로 완벽하게 깔끔한 코드로 변경할 수 있다. 

 

 

다형성을 도입한 후

Animal

public class Animal {
    public void sound() {
        System.out.println("동물 소리");
    }
}

 

Dog

public class Dog extends Animal {

    @Override
    public void sound() {
        System.out.println("멍멍");
    }
}

 

Cat

public class Cat extends Animal {

    @Override
    public void sound() {
        System.out.println("냥");
    }
}

 

Caw

public class Caw extends Animal {

    @Override
    public void sound() {
        System.out.println("음메");
    }
}

 

Main

public class Main {
    public static void main(String[] args) {
        Dog dog = new Dog();
        Cat cat = new Cat();
        Caw caw = new Caw();

        sound(dog);
        sound(cat);
        sound(caw);
    }

    private static void sound(Animal animal) {
        animal.sound();
    }
}

 

Main 코드를 보자. 너무 아름답지 않은가? 어떤 인스턴스를 만들던 Animal을 상속받는 클래스라면 그 인스턴스 참조값을 Animal 타입 변수에 넣어주면 문제 없이 업캐스팅이 된다. 그리고 이때 Animal 클래스가 가지고 있는 메서드 sound()를 실행하면 그 메서드를 오버라이딩 한 클래스라면 자기의 메서드가 무조건 우선순위를 가지기 때문에 더 고려할 것도 없이 각자 소리를 낸다.

 

실행결과:

멍멍
냥
음메

 

이게 바로 다형적 참조메서드 오버라이딩의 조합으로 이루어낸 강력한 코드이다. 그리고 여기서 주는 또다른 강력한 부분은 Animal을 상속받는 클래스가 계속해서 생겨도 저 sound()라는 메서드는 바뀔 부분이 단 한군데도 없다는 것이다.

 

더 나아가 이 코드 또한 더 깔끔하게 리팩토링 할 수도 있다. 다음은 완벽하게 리팩토링한 코드라고 볼 수 있다. 그냥 배열에 넣고 루프로 돌려버리는 것이다. 그러면 아까 따로 만든 sound() 메서드도 필요없다. 그냥 Animal 클래스의 sound()를 호출하면 각자 인스턴스 타입에 맞게 오버라이딩한 메서드가 실행될테니. 

public class Main {
    public static void main(String[] args) {
        Animal[] animals = {new Dog(), new Cat(), new Caw()};

        for (Animal animal : animals) {
            animal.sound();
        }
    }
}

 

 

완벽하다..! ... 완벽할까..? 남은 문제가 있다. 그것도 2개나.

  • Animal이라는 클래스를 만들 수 있는 문제
    • 생각해보자. Animal은 동물이다. 추상적인 개념이지 개, 고양이, 소처럼 실존하는게 아니다. 그리고 실제로 이 Animal 객체를 만들일도 없다. 근데 지금 코드에서는 만들 수 있다. 
  • Animal을 상속받은 클래스에서 sound()라는 메서드를 오버라이딩 안 할 수 있는 문제
    • 예를 들어 Duck이라는 새로운 동물 클래스를 만들었는데 개발자가 실수로 sound() 메서드를 오버라이딩을 안 할 수 있다. 충분히 가능성 높은 이야기다. 그럼 오버라이딩 메서드가 없으니 부모인 Animal의 sound() 메서드가 호출될것이다. 이런 문제가 있다.

 

그럼 이 문제를 어떻게 해결해야 할까? '추상화'라는 개념이 있다. 그 녀석을 알아보자.

 

추상 클래스

추상 클래스는 위 Animal 클래스와 같이 부모 클래스는 제공하지만, 실제 생성되면 안되는 클래스를 추상 클래스라고 한다. 이름 그대로 추상적인 개념을 제공하는 클래스이다. 따라서 실체인 인스턴스가 존재하지 않는다. 대신에 상속을 목적으로 사용되고, 부모 클래스 역할을 담당한다.

public abstract class Animal {...}
  • 추상 클래스는 클래스를 선언할 때 앞에 추상이라는 의미의 abstract 키워드를 붙여주면 된다.
  • 추상 클래스는 기존 클래스와 완전히 같다. 다만 new Animal()과 같이 직접 인스턴스를 생성하지 못하는 제약이 추가된 것이다.
    • 정확히 위 문제의 1번 "Animal이라는 클래스를 만들 수 있는 문제"를 해결한다.
  • 추상 메서드가 하나라도 있으면 추상 클래스가 되어야 한다.
  • 추상 클래스라고 할지라도 추상 메서드만 있어야 하는건 아니고 바디가 있는 메서드도 있을 수 있다. 이 목적은 상속받는 자식 클래스가 가져다가 사용할 수 있게함이다.

추상 메서드

부모 클래스를 상속 받는 자식 클래스가 반드시 오버라이딩 해야 하는 메서드를 부모 클래스에 정의할 수 있다. 이것을 추상 메서드라 한다. 추상 메서드는 이름 그대로 추상적인 개념을 제공하는 메서드이다. 따라서 실체가 존재하지 않고, 메서드 바디가 없다.

public abstract void sound();
  • 추상 메서드는 선언할 때 메서드 앞에 추상이라는 의미의 abstract 키워드를 붙여주면 된다.
  • 추상 메서드가 하나라도 있는 클래스는 추상 클래스로 선언해야 한다.
    • 그렇지 않으면 컴파일 오류가 발생한다.
    • 추상 메서드는 메서드 바디가 없다. 따라서 작동하지 않는 메서드를 가진 불완전한 클래스로 볼 수 있다. 따라서 직접 생성하지 못하도록 추상 클래스로 선언해야 한다.
  • 추상 메서드는 상속 받는 자식 클래스가 반드시 오버라이딩해서 사용해야 한다.
    • 그렇지 않으면 컴파일 오류가 발생한다.
    • 추상 메서드는 자식 클래스가 반드시 오버라이딩 해야 하기 때문에 메서드 바디 부분이 없다. 바디 부분을 만들면 컴파일 오류가 발생한다.
    • 위 문제의 2번 "Animal을 상속받은 클래스에서 sound()라는 메서드를 오버라이딩 안 할 수 있는 문제"를 해결한다.
    • 자식 클래스가 오버라이딩 하지 않으면 자식 클래스도 추상 클래스가 되어야 한다.

 

이 개념을 토대로 코드를 작성해보자.

 

Animal (추상클래스)

public abstract class Animal {
    public abstract void sound();
}

 

Cat

public class Cat extends Animal {
    @Override
    public void sound() {
        System.out.println("냐옹");
    }
}

 

Caw

public class Caw extends Animal {
    @Override
    public void sound() {
        System.out.println("음메");
    }
}

 

Dog

public class Dog extends Animal {

    @Override
    public void sound() {
        System.out.println("멍멍");
    }
}

 

추상 클래스를 상속 받는 자식 클래스는 반드시 추상 클래스가 가진 추상 메서드를 구현해야 한다.

그렇지 않으면 컴파일 에러가 발생한다. 다음이 그 화면이다.

이로 인해 자식 클래스에서 오버라이딩 하는 것을 까먹는 문제를 완벽하게 해결할 수 있다.

 

 

Main

public class Main {
    public static void main(String[] args) {

        Dog dog = new Dog();
        Cat cat = new Cat();
        Caw caw = new Caw();

        soundAnimal(dog);
        soundAnimal(cat);
        soundAnimal(caw);
    }

    public static void soundAnimal(Animal animal) {
        System.out.print("울음소리: ");
        animal.sound();
    }
}

 

코드는 아까와 같지만 제약덕분에 실수를 방지할 수 있게 됐다. 만약 Animal 이라는 추상 클래스에 대한 인스턴스를 만드려고 하면 에러가 발생한다. 같은 코드이지만 더 완전한 코드가 된 것이다.

 

 

인터페이스

지금까지 추상 클래스가 무엇인지 알아봤는데, 위에서 언급했지만 추상 클래스에는 추상 메서드만 있을수도 있고 바디가 있는 메서드가 있을 수도 있다고 했다. 근데 여기서 의도에 따라 달라지는데 나의 의도는 오로지 다형성을 위해 추상 클래스를 만드는 것이고 추상 클래스 내 메서드를 오버라이딩 하도록 강제하게 하는 것이다. 그럼 바디가 있는 메서드가 필요하지 않다. 그런 의도라면 아예 바디가 있는 메서드를 만들 수 없도록 하면 더더욱 확실한 안전 장치가 되지 않을까? 그게 인터페이스다.

 

Abstract Class

public abstract class Animal {
    public abstract void sound();
}

 

Interface

public interface Animal {
    void sound();
}

 

둘 간 차이는 인터페이스는 키워드가 'interface'이고 메서드에 public abstract 라는 키워드가 없어도 된다. 없는 것을 권장한다.

그리고 인터페이스에서는 바디가 있는 메서드를 만들면 다음과 같이 컴파일 에러가 난다. 

 

그래서 인터페이스라는 것을 사용하면 더더욱 확실하게 사용자의 의도를 파악할 수 있다. "아! 다형성과 메서드 오버라이딩을 사용하려는 목적이구나" 인터페이스는 이런 장점이 있다. 그리고 인터페이스는 다중 구현이 가능하다. 클래스는 상속을 하나만 받을 수 있는데 인터페이스는 여러개를 받을 수 있다. 참고로 클래스는 상속이라고 표현하고 인터페이스는 구현이라고 표현한다.

 

인터페이스에서는 멤버 변수를 선언할 수 있다. 그리고 인터페이스에선 멤버 변수에 public static final 키워드가 모두 포함되었다고 간주한다. 그리고 public static final을 생략하는 것을 권장한다. 그래서 상수를 어딘가에 정의하고 싶으면 인터페이스에 정의하면 좋다.

public interface Animal {
    double PI = 3.14;
    
    void sound();
}

 

추상 클래스 대신 인터페이스를 사용하여 위 예제를 그대로 적용해보면 다음과 같이 적용할 수 있다.

 

Animal (interface)

public interface Animal {
    void sound();
}

 

Cat

public class Cat implements Animal {

    @Override
    public void sound() {
        System.out.println("냐옹");
    }
}

 

Caw

public class Caw implements Animal {

    @Override
    public void sound() {
        System.out.println("음메");
    }
}

 

Dog

public class Dog implements Animal {

    @Override
    public void sound() {
        System.out.println("멍멍");
    }
}

 

 

Main

public class Main {
    public static void main(String[] args) {
        Dog dog = new Dog();
        Cat cat = new Cat();
        Caw caw = new Caw();

        soundAnimal(dog);
        soundAnimal(cat);
        soundAnimal(caw);
    }

    private static void soundAnimal(Animal animal) {
        animal.sound();
    }
}

 

똑같다. 다른것은 거의 없다. 그러나 인터페이스를 사용하므로 명확해졌다. "아! 다형성을 위함이구나." 라는 것을.

 

그럼 궁금한 것은 모든 메서드를 추상 메서드로 선언하는 추상클래스와 인터페이스 중 무엇을 사용해야 하나? 인터페이스를 사용하면 더 좋다.

 

public abstract 라는 키워드를 생략할 수 있다는 편리함 이런 차원이 아니라 다음과 같은 이유가 있다.

  • 제약: 인터페이스를 사용한다는 것 자체가 오로지 다형성과 메서드 오버라이딩을 사용하여 효율적이고 강력한 코드를 작성하기 위함이고 그 말은 바디가 있는 메서드 자체를 허용하지 않겠다라고 해석이 될 수 있다. 그러나, 추상 클래스로 만들면 시간이 지나 누군가가 이 사실을 까먹은 채 바디가 있는 메서드를 만들 가능성이 농후하다. 그 점까지도 배제하는 것이다.
  • 다중 구현: 클래스는 하나밖에 상속받을 수 없는 반면, 인터페이스는 여러개를 다중 구현할 수 있다.

 

좋은 프로그램은 제약이 있는 프로그램이다.

 

인터페이스의 다중 구현

인터페이스는 클래스와 달리 다중 구현이 가능하다. 왜 그럴까? 다음 그림을 보자.

위 그림에서 만약 다중 상속이 가능하다면 AirplaneCar 클래스가 Airplane, Car 두 클래스를 모두 상속받았다고 했을 때 과연 부모의 move() 메서드를 누구의 것으로 호출해야 할까? 알 수 없다. 방법이 없다. 이래서 안되는것이다.

 

근데 인터페이스는? 무조건 상속받으면 오버라이딩을 해야하며, 인터페이스에서 선언한 메서드는 바디가 없다. 즉, 어떤 인터페이스 타입으로 인스턴스를 만들던간에 호출한 move()라는 메서드는 결국 본인 자신것으로 호출된다. 이게 다형성의 메서드 오버라이딩 우선순위니까.

 

 

다형성의 핵심 개념 1. 다형적 참조 2. 메서드 오버라이딩 개념을 잘 이해했다면 문제 없이 이해가 될 것이다.

이 내용을 실제로 코드로 작성해보자. 어렵지 않다.

 

InterfaceA

public interface InterfaceA {
    void methodA();

    void methodCommon();
}

 

InterfaceB

public interface InterfaceB {
    void methodB();

    void methodCommon();
}

 

Child

public class Child implements InterfaceA, InterfaceB {
    @Override
    public void methodA() {
        System.out.println("method A");
    }

    @Override
    public void methodB() {
        System.out.println("method B");
    }

    @Override
    public void methodCommon() {
        System.out.println("method common");
    }
}

Child 클래스를 보면 InterfaceA, InterfaceB를 다중 구현한다. 저 두 개의 인터페이스가 가진 메서드들을 모두 구현해야 하며 methodCommon() 같은 경우 둘 다 동일하게 가진 시그니쳐이기 때문에 하나만 구현하면 된다. 

 

 

Main

public class Main {
    public static void main(String[] args) {
        InterfaceA a = new Child();
        InterfaceB b = new Child();

        a.methodA();
        a.methodCommon();

        b.methodB();
        b.methodCommon();
    }
}

가져다가 사용하는 부분을 보면 InterfaceA 타입에 Child 인스턴스를 참조하던, InterfaceB 타입에 Child 인스턴스를 참조하던 Child 인스턴스를 생성하면 해당 참조 공간에는 InterfaceA, InterfaceB, Child 이렇게 세 개 모두 생성된다. 여기서 변수 amethodA()를 호출할 수 있고 methodCommon()도 호출 가능하다. 반면 bmethodB()를 호출할 수 있고 methodCommon()도 호출 가능하다.

 

실행결과:

method A
method common
method B
method common

 

결국 InterfaceA 타입의 변수나 InterfaceB 타입의 변수나 구현(상속)을 받았기 때문에 메서드를 호출하는 시점에 오버라이딩이 됐다면 오버라이딩 된 메서드를 호출한다. 그래서 Child 클래스에 존재하는 methodCommon()을 실행하기 때문에 어떤걸 호출해야하지? 라는 문제 자체가 없는것. 그래서 인터페이스는 다중 구현이 가능하고 클래스는 다중 상속이 불가능하다. 

 

 

클래스와 인터페이스를 병행

다음과 같은 내용들을 배웠다.

  • 추상 클래스를 이용해서 추상 메서드를 사용하기
  • 인터페이스를 이용해서 추상 메서드를 사용하기
  • 인터페이스 또는 추상 클래스를 구현(상속)하면 추상 메서드를 반드시 오버라이딩할 것
  • 다형적 참조와 메서드 오버라이딩 개념을 인터페이스에 적용해서 다형성이 주는 막강한 기능 이용해보기
  • 어떤 클래스나 인터페이스를 상속(구현)하고 메서드를 오버라이딩 했으면 타입이 부모 타입이어도 오버라이딩 메서드가 우선순위를 가진다.

이러한 여러 개념들과 메모리 구조를 잘 떠올려서 확실하게 내 것으로 만들고 가자. 이제는 진짜 다형성과 추상 클래스, 인터페이스에 대한 이해가 깊어진 느낌을 받는다. 왜 인터페이스를 사용하는지 다형성이 왜 나타난건지 이 다형성을 구현하면 어떤 이점을 주는지 전부 알 것 같다. 인터페이스를 사용하는 이유는 이 인터페이스를 어떤 방식으로 얼마나 많은 구현 클래스가 존재하던 부모 타입(인터페이스)으로 모든 하위 구현 클래스를 받을 수 있고 (InterfaceA a = new Child();) 무조건 메서드 오버라이딩이 필요하기 때문에 어떤 자식 인스턴스를 참조하던 그 녀석이 구현한 메서드가 실행된다는 것을 안다. 

 

이제 그래서 좀 더 실전으로 가보자. 클래스와 인터페이스를 모두 사용해서 만들어보자.

 

Animal

public abstract class Animal {
    public abstract void sound();
    public void move() {
        System.out.println("동물들이 움직인다.");
    }
}

추상 메서드와 바디가 있는 메서드 두 개가 있는 추상클래스다.

 

Fly

public interface Fly {
    void fly();
}

fly() 메서드를 가진 인터페이스.

 

Dog

public class Dog extends Animal {
    @Override
    public void sound() {
        System.out.println("멍멍");
    }
}

Dog 클래스. 이 클래스는 Animal 추상 클래스만을 상속받는다.

 

Cat

public class Cat extends Animal {
    @Override
    public void sound() {
        System.out.println("냥냥");
    }
}

Cat 클래스. 이 클래스도 Animal 추상 클래스만을 상속받는다.

 

Bird

public class Bird extends Animal implements Fly {
    @Override
    public void sound() {
        System.out.println("짹짹");
    }

    @Override
    public void fly() {
        System.out.println("하늘을 펄펄");
    }
}

핵심이 되는 Bird 클래스. 이 클래스는 Animal 추상클래스를 상속받음과 동시에 Fly라는 인터페이스를 구현한다.

그래서 클래스와 인터페이스를 두개 다 사용할수도 있다. 뭐 간단하다. 

 

Main

public class Main {
    public static void main(String[] args) {
        Dog dog = new Dog();
        Bird bird = new Bird();
        Cat cat = new Cat();

        soundAnimal(dog);
        soundAnimal(bird);
        soundAnimal(cat);

        flyAnimal(bird);
    }

    public static void soundAnimal(Animal animal) {
        System.out.println("동물들의 소리");
        animal.sound();
    }

    public static void flyAnimal(Fly fly) {
        System.out.println("날아봐. 날 수 있다면 말이지.");
        fly.fly();
    }
}

그리고 다형성을 사용하는 Main 클래스. Dog, Bird, Cat 클래스의 인스턴스를 각각 만들고 Animal 이라는 부모 타입으로 파라미터를 받는 soundAnimal() 메서드를 만든다. 저 세개의 인스턴스는 모두 다 이 메서드를 사용할 수 있다. 인스턴스를 생성할 때 부모도 같은 참조 공간에 만들어지니까. 그리고 해당 메서드에서 sound() 메서드를 호출한다. 이 메서드는 각각의 Dog, Bird, Cat이 무조건 오버라이딩 해야하는 추상메서드이다. 그리고 그 추상메서드를 오버라이딩하면 부모 타입으로 된 변수 animal 일지라도 오버라이딩 한 메서드를 호출한다. 이게 바로 다형성.

 

flyAnimal() 메서드도 위와 일맥상통하는데 이번엔 파라미터에 인터페이스 타입을 집어넣었다. 마찬가지로 인터페이스를 구현하는 인스턴스는 이 메서드를 사용할 수 있다. 업캐스팅은 어떠한 문제도 되지 않으니까. 

 

아래 그림을 이해하면 된다.

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

java.lang 패키지와 그 첫번째 - Object  (0) 2024.03.31
OCP (Open-Closed Principle) 원칙  (0) 2024.03.30
다형성 (Part.1) 매우중요 ✨  (0) 2024.03.28
상속 (Part.2)  (0) 2024.03.28
상속 (Part.1)  (0) 2024.03.28
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

객체지향 프로그래밍의 대표적인 특징으로는 캡슐화, 상속, 다형성이 있다. 그 중 다형성은 객체지향 프로그래밍의 꽃이라 불린다.

캡슐화나 상속은 직관적으로 이해하기 쉽다. 그러나 다형성은 제대로 이해하기도 어렵고, 잘 활용하기는 더 어렵다. 하지만 좋은 개발자가 되기 위해선 다형성에 대한 이해가 필수다.

 

다형성(Polymorphism)

이름 그대로 "다양한 형태"를 뜻한다. 자바에서 다형성은 한 객체가 여러 타입의 객체로 취급될 수 있는 능력을 뜻한다. 보통은 하나의 객체는 하나의 타입으로 고정되어 있는데, 다형성을 이용하면 하나의 객체가 다른 타입으로 사용될 수 있다는 뜻이다. 

 

다형성을 이해하기 위해서는 크게 2가지 핵심 이론을 알아야 한다.

  • 다형적 참조
  • 메서드 오버라이딩

 

다형적 참조

다음과 같은 관계를 가지는 두 클래스를 생각해보자. 

 

부모와 자식이 있고 각각 다른 메서드를 가진다.

Parent

public class Parent {
    public void parentMethod() {
        System.out.println("Parent.parentMethod");
    }
}

 

Child

public class Child extends Parent {
    public void childMethod() {
        System.out.println("Child.childMethod");
    }
}

 

Main

public class Main {
    public static void main(String[] args) {
        System.out.println("Parent -> Parent");
        Parent parent = new Parent();
        parent.parentMethod();

        System.out.println("Child -> Child");
        Child child = new Child();
        child.parentMethod();
        child.childMethod();

        // 부모 변수가 자식 인스턴스를 참조 (다형적 참조)
        System.out.println("Parent -> Child");
        Parent poly = new Child();
        poly.parentMethod();
        // poly.childMethod(); 자식의 메서드는 호출할 수 없다.

        // Child child1 = new Parent(); // 자식은 부모를 담을 수 없다.
    }
}

 

Main에서 보면 부모 타입 변수가 부모 인스턴스를 참조하거나, 자식 타입 변수가 자식 인스턴스를 참조하는건 참 많이 봐왔다. 그런데 그 아래 부모 타입 변수가 자식 인스턴스를 참조하고 있다. 이것을 다형적 참조라고 한다. 이게 어떻게 가능한지는 객체와 메모리 구조를 잘 떠올려보면 납득이 된다.

 

부모 타입의 변수가 부모 인스턴스 참조 Parent parent = new Parent();

1. 우선 부모 인스턴스를 새로 생성하고

2. 그 참조값(x001)을 부모 타입의 변수에 넣는다.

3. 부모 인스턴스를 생성하면 해당 인스턴스가 힙 영역에 다음과 같이 생긴다.

4. 더 이상 설명할 내용이 없을 정도로 간결하다. 당연히 자식 인스턴스는 같은 참조에 속하지 않는다. 부모 입장에서는 자식이 누군지도 모르기 때문이다.

 

그리고 이런 이유 때문에 Child child = new Parent(); 는 불가한 것이다. 왜냐? Parent 인스턴스를 생성하면 Child는 만들어지지 않는다. Parent는 어떤 클래스가 나를 상속받는지 정보가 아무것도 없기 때문에 이런 구조에서 Child 타입의 변수는 해당 참조값을 가지고 있을 수 없는것.

 

자식 타입의 변수가 자식 인스턴스 참조 Child child = new Child();

1. 자식 인스턴스를 생성한다.

2. 그 참조값(x001)을 자식 타입의 변수에 넣는다.

3. 자식 인스턴스를 생성하면 자식 인스턴스와 함께 상속관계에 있는 부모 인스턴스도 같은 참조(x001)에 만들어진다. 

이 구조가 만들어지기 때문에 자식 타입의 변수에서 부모의 메서드를 호출하더라도 참조값(x001)이 가르키는 참조에 찾아가서 첫번째로 본인의 타입인 Child부터 확인해서 없으면 부모로 계속 거슬러 올라가서 확인할 수 있으니 부모의 메서드 호출이 가능해진다.

 

다형적 참조: 부모 타입의 변수가 자식 인스턴스를 참조: Parent poly = new Child();

여기가 중요하다. 우선 메모리에 어떻게 올라갈지 먼저 생각해보자. 자식 인스턴스를 생성했다. 그럼 자식 인스턴스가 메모리에 올라갈텐데 자식 인스턴스는 상속받는 부모도 가지고 있다. 그럼 자식 인스턴스의 참조값(x001)에 가보면 메모리에는 다음과 같이 부모와 자식 인스턴스 모두가 생성된 상태일것이다. 그래서 타입을 부모 타입으로 선언해도 문제가 없는것이다. 해당 참조값이 가르키는 메모리 상엔 부모 객체도 존재하니까.

1. 부모 타입의 변수가 자식 인스턴스를 참조한다. Parent poly = new Child();

2. Child 인스턴스를 만들었다. 이 경우 자식 타입인 Child를 생성했기 때문에 메모리 상에 Child와 Parent가 모두 생성된다.

3. 생성된 참조값을 Parent 타입의 변수인 poly에 담아둔다.

 

Parent 타입의 변수는 다음과 같이 자신인 Parent는 물론이고, 자식 타입까지 참조할 수 있다. 만약 그 하위에 또 다른 자식이 있다면 그것도 가능하다. 

  • Parent poly = new Parent()
  • Parent poly = new Child()
  • Parent poly = new Grandson()

Grandson -> Child -> Parent 이런 구조로 상속받는 형태라면 new Grandson()을 실행하면 메모리에는 총 3개의 객체가 한 참조를 만들것이다. 그렇기 때문에 이 경우 Child, Parent 타입 변수 new Grandson()을 받아들일 수 있다.

 

즉, 자신을 기준으로 모든 자식 타입을 참조할 수 있다. 이것이 바로 다양한 형태를 참조할 수 있다고 해서 다형적 참조라고 한다.

 

다형적 참조의 한계

Parent poly = new Child() 이렇게 자식 인스턴스를 참조한 상황에서 poly가 자식 타입인 Child에 있는 childMethod()를 호출하면 어떻게 될까? 호출할 수 없다. 아래는 그 흐름인데 한번 보자.

 

1. poly.childMethod()를 실행하면 먼저 참조값을 통해 인스턴스를 찾는다.

2. 그리고 다음으로 인스턴스 안에서 실행할 타입을 찾아야 한다. 타입이 Parent이므로 Parent 클래스부터 시작해서 필요한 기능을 찾는다. 그런데 상속 관계는 부모 방향으로 찾아 올라갈 수는 있지만 자식 방향으로 찾아 내려갈 수 없다. Parent는 부모 타입이고 상위에 부모가 없다. 따라서 childMethod()를 찾을 수 없으므로 컴파일 오류가 발생한다.

 

 

아니 그럼, childMethod()를 못쓰는데 이거 어쩐담?

다형성과 캐스팅

Parent poly = new Child();

이 코드 한 줄은 poly라는 Parent 타입의 변수에 Child 인스턴스를 참조하게 한다. Child 인스턴스를 만들 때 부모인 Parent 인스턴스 역시 하나의 참조값에 포함되어 만들어지는데 이때 poly라는 변수의 타입이 Parent이므로 Child가 가지는 메서드는 실행할 수 없다. 상속 관계는 부모로만 찾아서 올라갈 수 있다.

 

그러면 childMethod()를 정말 너무 쓰고 싶은데 어떻게 하면 좋을까? 다운캐스팅을 하면 된다. 다음 코드를 보자.

public class Main {
    public static void main(String[] args) {
        Parent parent = new Child();
        // parent.childrenMethod(); 컴파일 에러

        Child child = (Child) parent;
        child.childMethod();
    }
}

Parent 타입의 변수 parent에 들어있는 참조값을 가지고 Child 타입의 변수 child에 대입을 한다. 근데 대입할 때 앞부분에 (Child)를 추가해주면 이게 바로 다운 캐스팅이다. 다운 캐스팅은 자식으로 형변환을 하는것이다. 이러면 childMethod()를 호출할 수 있다.

 

1. Child 인스턴스를 생성한다. 생성할 때 부모인 Parent도 같이 생성된 하나의 참조가 만들어진다.

2. 만들어진 참조의 참조값을 Parent 타입의 변수에 넣는다.

3. 해당 변수는 타입이 Parent이므로 자식의 메서드를 사용하지 못한다.

4. 자식의 메서드를 기어코 사용하기 위해 자식 타입으로 다운캐스팅을 한다.

5. 자식 메서드를 사용한다.

참고로 캐스팅을 한다고 해서 Parent poly의 타입이 변하는 것은 아니다. 해당 참조값을 꺼내고 꺼낸 참조값이 Child 타입이 되는것이다. 따라서 poly의 타입은 Parent로 기존과 같이 유지된다.

 

캐스팅시 주의

근데 위 코드를 파헤쳐보면 많은 것들이 나온다. 일단 첫번째, 다운캐스팅이란 단어가 있다는 것은 업캐스팅도 있다는 것을 내포한다. 맞다. 업캐스팅도 있고 업캐스팅은 부모로 캐스팅하는 것이다. 그리고 우리 이미 이건 해봤다. 

Parent parent = new Child();

이 코드가 바로 업캐스팅이다. 왜냐고? 자식 인스턴스를 부모 타입의 변수에 참조하니까. 저 코드는 사실 이런 모양이다. 

Parent parent = (Parent) new Child();

근데 자바에서 생략이 가능하게 해준다. 실제로 IDE에서 작성해보면 다음과 같이 회색 불빛으로 굳이 쓸 필요없다고 말해준다.


그럼 업캐스팅은 생략을 해주는데 왜 다운캐스팅은 생략을 안해줄까? 다운 캐스팅은 런타임 에러가 발생할 가능성이 있기 때문이다.

자 다음 코드를 보자. Parent 타입의 parentChild로 다운캐스팅한다. 그리고 다운캐스팅을 했으니 자식 타입의 메서드 cMethod()를 호출할 수 있다.

 

아, 그리고 이 과정이 귀찮다. 그래서 일시적 캐스팅이란게 있다. 다음 코드를 보자. 이렇게 하면 변수를 굳이 만들지 않아도 된다.

 

자 위 코드는 문제없이 잘 동작한다. 근데 다음 코드를 보자. 중요! 

위 코드가 동작할까? 아니다. 런타임 에러가 발생한다.

 

에러 내용은 ClassCastException이다. 왜 이런 에러가 발생할까? 근데 우리는 알고 있다. 객체와 메모리 구조를 다시 한번 떠올려보자.

new Parent()Parent 인스턴스를 메모리 상에 만들어낸다. 그러나 본인을 상속하는 자식은 알 길이 없기 때문에 참조에 어떠한 자식도 같이 만들어지지 않는다. 그러니까 그 참조에서 Child는 없기 때문에 Child로 다운캐스팅은 할 수가 없는것이다. 다음이 그 그림이다.

 

 

 

그러나 Parent parent = new Child();는 문제가 없다. 이것도 메모리 구조를 잘 떠올려보면 Child 인스턴스를 만들 때 부모 클래스에 대한 인스턴스 역시 같이 만들어진다. 그렇기 때문에 부모 타입의 parent라는 변수가 자식으로 다운캐스팅이 되어도 아무런 문제가 없이 잘 동작할 수 있게된다. 그 그림은 다음과 같다.

 

이러한 이유 때문에 다운 캐스팅은 위험하다고 하는 것이다. 그럼 왜 업캐스팅은 안전할까?

업캐스팅은 에러가 날 수가 없다. 왜냐면 더 상위 타입으로 형변환을 하는것인데 그 말은 자식 인스턴스를 만들어낸다는 것이고 자식 인스턴스를 만들 때 당연히 부모 인스턴스도 하나의 참조에 만들어지기 때문에 무조건 캐스팅이 잘된다.

 

다음 코드가 대표적인 업캐스팅 코드이다.

Parent parent = new Child();

Child 인스턴스를 만들 때 메모리 상에 참조 내에는 Child, Parent 인스턴스 둘 다 만들어진다. 부모를 같이 만드니까.

그러니까 당연히 부모타입으로 업캐스팅이 가능한 것. 절대 문제가 발생할 수가 없는 것.

 

그러면, 다운 캐스팅을 하고 싶은데 너무 하고 싶은데 이게 문제를 발생시킬까? 아닐까?를 IDE에서 조차 알려주지 않기 때문에(런타임 에러) 아예 사용하지 말아야할까? 내가 다운캐스팅하려고 하는 이 녀석이 참조하는 참조 인스턴스가 다운 캐스팅할 녀석보다 상위 클래스인지 알 방법은 없을까? 그러니까 아래 코드처럼 parentChild로 다운캐스팅하고 싶을 때 이 parent가 참조하는 인스턴스가 Child보다 상위 클래스인지 아닌지 알 수 있는 방법 말이다.

Parent parent = new Child();

 

 

instanceof

어떤 인스턴스를 참조하고 있는지 알 수 있는 방법이다. 다음 코드를 보자.

callParent() 메서드는 파라미터로 Parent parent를 받는다. 이 때 변수 parentParent 인스턴스를 참조하고 있을수도 있고 Child 인스턴스를 참조할 수도 있다. ChildParent를 상속받기 때문에 가능한 일인데, 원하는 것은 Parent 타입의 변수가 자식 클래스의 메서드를 사용하고 싶은 것이다. 그러려면 다운캐스팅을 해줘야한다. 그러나 위에서 말했지만 다운캐스팅은 위험하다. 에러가 발생할 소지가 있기 때문인데 그래서 에러를 방지하기 위해 instanceofChild 인스턴스를 참조하는 변수인지 확인을 하는 코드다.

 

 

참고로, instanceof 키워드는 왼쪽에서 참조하는 인스턴스가 오른쪽 타입에 대입될 수 있는지를 확인해보면 된다. 

new Parent() instanceof Parent // true
new Parent() instanceof Child // false
new Child() instanceof Parent // true
new Child() instanceof Child // true

 

그러니까 이것도 객체와 메모리 구조를 떠올려보자. 왼쪽에 인스턴스가 메모리 상에 어떻게 올라가는지 확인해보자. 

new Parent()를 하면 메모리에는 Parent 인스턴스만 참조 공간에 만들어진다. 이 상태에서 Parent 타입에는 대입이 가능하고 Child 타입에는 당연히 불가능하다. new Child()를 하면 메모리에는 Parent, Child 두 개의 인스턴스 모두 참조 공간에 만들어진다. 그래서 Parent 타입으로 대입도 가능하고 Child 타입으로 대입도 가능하다.

 

그리고 자바 16부터 가능한 기능인데 뭐 그냥 알아두면 나쁠건 없는 기능이다. 다음 코드를 보자.

여기서 parent가 참조하는 참조값이 Child 인스턴스라면 다운 캐스팅을 하는데 이걸 이렇게 한번에 쓸 수 있다. 변수를 선언해버리는 것이다.

 

 

다형성의 메서드 오버라이딩

다형성은 하나의 변수 타입으로 여러 자식 인스턴스를 참조할 수 있는 것을 말한다. 이것을 다형적 참조라고 하는데 다형성의 또 다른 핵심 주제 하나는 메서드 오버라이딩이다. 다음 코드를 보자.

 

Parent

public class Parent {
    public String value = "parent";

    public void method() {
        System.out.println("Parent method");
    }
}

 

Child

public class Child extends Parent {
    public String value = "child";

    @Override
    public void method() {
        System.out.println("Child method");
    }
}

 

ParentChild 클래스 모두 value라는 필드를 가진다. 필드는 오버라이딩의 개념이 없다. 둘 다 각자 가지고 있는 필드인거고 method()라는 메서드를 Child에서 오버라이딩했다. 코드 자체는 굉장히 간단하다.

 

Main

public class Main {
    public static void main(String[] args) {
        Parent parent = new Parent();
        System.out.println(parent.value);
        parent.method();

        Child child = new Child();
        System.out.println(child.value);
        child.method();

        Parent poly = new Child();
        System.out.println(poly.value);
        poly.method();
    }
}

 

실행결과:

parent
Parent method
child
Child method
parent
Child method

 

신기한 일이 벌어졌다. 당연히 위에 두 실행결과는 납득이 된다. Parent 타입 변수에 Parent 인스턴스를 참조했으니 Parent가 가지고 있는 value와 method()가 호출될테고 Child도 마찬가지다. 그림을 통해서 좀 더 명확히 확인해보자.

child 변수는 Child 타입이다. 따라서 child.value, child.method()를 실행하면 인스턴스의 Child 타입에서 기능을 찾아서 수행한다.

 

parent 변수는 Parent 타입이다. 따라서 parent.value, parent.method()를 호출하면 인스턴스의 Parent 타입에서 기능을 찾아 수행한다. 사실 new Parent()로 인스턴스를 만들면 인스턴스 공간에 Child 자체가 없기 때문에 Child의 뭐든 가져올수도 없다.

 

 

여기서가 중요하다. Parent 타입 변수에 Child 인스턴스를 참조했더니 필드는 그대로 Parent의 필드를 반환했지만 method()는 Child의 method()를 반환했다. 

무슨 일일까? 객체와 메모리 구조를 상기해보면 Child라는 인스턴스를 생성할 때 부모인 Parent도 같은 참조 공간에 만들어지고 변수 타입이 Parent니까 분명 참조 공간에서 Parent를 바라보면 Parent의 method()가 실행됐어야 했는데 Child method()가 실행됐다. 

메서드는 오버라이딩 한 메서드가 무조건 우선순위를 가진다.

 

 

이 다형성과 메서드 오버라이딩이 강력하고 어마어마한 코드 작성을 도와준다. 이제 이 개념을 사용해볼 차례다.

 

사실, 이 내용이 스프링의 핵심 패턴인 전략 패턴과 매우 밀접한 관계가 있다. 생각해보자. 인터페이스를 만들고 그 인터페이스에서 추상 메서드를 정의했다. 그리고 그 인터페이스를 구현한 구현클래스를 만들었다. 그 구현 클래스에 "@Service" 애노테이션을 붙이면 스프링이 자동으로 해당 클래스를 인터페이스 타입의 참조값으로 으로 등록하잖아? 그리고 우리가 특정 서비스나 컨트롤러에서 DI를 하면 그 DI되는 인스턴스는 빈으로 등록한 그 녀석이 된다. 그래서 인터페이스의 메서드를 호출하면 빈으로 등록한 클래스의 메서드가 실행되는게 이 원리이다. 인터페이스는 하나, 그것을 구현한 구현클래스는 여러개일지언정 이 코드를 사용하는 클라이언트 소스에서는 전혀 변경할 필요없이 빈 등록만 다른걸로 바꿔주면 되는것 이게 바로 OCP이고. 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

OCP (Open-Closed Principle) 원칙  (0) 2024.03.30
다형성 (Part.2) 사용하기 ✨  (0) 2024.03.29
상속 (Part.2)  (0) 2024.03.28
상속 (Part.1)  (0) 2024.03.28
final  (0) 2024.03.27
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

super

상속과 메모리 구조를 통해 봤다시피 어떤 타입으로 인스턴스를 생성했냐에 따라 참조값으로 들어가서 먼저 확인하는 인스턴스가 달라졌다. 그런데 만약 부모와 자식이 이름이 같은 필드를 가지고 있거나 오버라이딩 메서드가 있을 때 내가 자식 타입으로 인스턴스를 생성했는데 부모의 필드나 메서드에 접근하고 싶으면 어떻게 할까? 이때 사용하는 키워드가 super이다.

 

Car

public class Car {
    public int wheelCount = 4;
    public void move() {
        System.out.println("이동");
    }
}

 

ElectricCar

public class ElectricCar extends Car {
    public int wheelCount = 10;
    public void charge() {
        System.out.println("충전");
    }

    @Override
    public void move() {
        System.out.println("전기차 이동");
    }

    public void getWheelCount() {
        System.out.println(this.wheelCount);
        System.out.println(super.wheelCount);
    }

    public void showMove() {
        this.move();
        super.move();
    }
}

 

부모와 자식 클래스모두 wheelCount 라는 필드를 가지고 있고 move()라는 메서드가 있을 때 자식 클래스에서 본인의 필드나 메서드말고 부모의 필드나 메서드를 가져다가 사용하고 싶으면 super를 사용하면 된다. (getWheelCount(), showMove() 확인)아주 간단하다.

 

 

super 생성자

이전에 특정 클래스에서 생성자를 다른것을 호출하고 싶을 때 this()로 호출했던것이 기억날 것이다. this니까 본인의 생성자 중 특정 무언가를 호출하는거고 super()라면? 부모의 생성자 중 특정 무엇을 호출한다고 보면 된다. 근데 상속받았다면 생성자에 이 super()는 필수다.

 

어? 나는 super() 썼던 기억이 없는데? => 생략가능할 뿐이다.

 

부모 클래스의 기본 생성자를 호출하는 super()는 생략이 가능하다. 그러나 부모 클래스에 기본 생성자가 없다면 반드시 명시적으로 작성해줘야 한다. 다음 코드를 보자.

 

A

public class A {
    public A() {
        System.out.println("A 생성자 호출");
    }
}

 

B

public class B extends A {

    public B() {
        super(); // 생략 가능
        System.out.println("B 생성자 호출");
    }
}

 

Main

public class Main {
    public static void main(String[] args) {
        B b = new B();
    }
}

 

실행결과

A 생성자 호출
B 생성자 호출

종료 순서는 당연히 A가 먼저다. 스택으로 생각하자. 마지막에 호출된 게 가장 먼저 끝난다.

 

위 코드를 보면 BA를 상속받는다. 그리고 B 클래스의 인스턴스를 생성할 때 반드시 무조건 A 클래스의 생성자도 호출되어야 한다. 이건 규칙이다. 그러나 그 생성자가 기본 생성자라면 생략이 가능하다. 자바가 도와주기 때문에.

왜 그래야하냐면 메모리 구조를 떠올려보자. 부모 자식관계를 가진 클래스를 인스턴스로 생성하면 그 참조하는 곳엔 부모 객체와 자식 객체가 두개가 한곳에 생기는 것을 기억하는가? 

이 그림 그대로 자식을 만들 때 부모를 같이 만들면 당연히 부모도 생성자를 호출해야 부모 객체가 만들어질테니 super()는 필수다. 근데 이 필수 작업을 매번 해주기 귀찮으니 자바가 도와줄뿐이다. 근데 만약에 부모 생성자 중 기본 생성자가 없으면 반드시 명시적으로 작성해야한다. 다음 코드를 보자.

 

A

public class A {
    public A(int a) {
        System.out.println("A 생성자 호출: params = " + a);
    }
}

 

B

public class B extends A {

    public B() {
        System.out.println("B 생성자 호출");
    }
}

이런 부모 자식 관계를 가지는 코드가 있을 때 이는 컴파일 에러가 발생한다. 왜냐하면 A에는 기본 생성자가 없다. 개발자가 직접 생성자를 하나라도 만들면 자바는 기본적으로 생성자를 제공해주지 않는다. 그래서 기본 생성자가 없는데 B 클래스의 생성자에서 super()라는 기본 생성자를 호출하는 코드가 따로 없으면 기본으로 부모 클래스의 기본 생성자를 호출하는 'super()'를 호출하는데 부모에는 기본 생성자가 없으니 에러가 발생한다. 다음이 그 에러다.

 

그래서 이럴땐 반드시 명시적으로 작성해줘야한다.

 

그리고, 위에서 잠깐 언급한 this()를 사용해서 생성자에서 다른 생성자를 부를때 어떤 생성자가 됐던 반드시 하나의 super()는 호출이 되어야하고 된다. 아래처럼 this()를 사용한다고해도 반드시 어디서든 한번은 super()를 불러야한다.

그리고 super()this() 둘 중 하난 무조건 생성자에서 첫번째 줄에 와야한다. 둘 다 없으면 super()가 생략된 것.

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

다형성 (Part.2) 사용하기 ✨  (0) 2024.03.29
다형성 (Part.1) 매우중요 ✨  (0) 2024.03.28
상속 (Part.1)  (0) 2024.03.28
final  (0) 2024.03.27
자바 메모리 구조 ✨  (0) 2024.03.27
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

덜 중요한 부분: 규칙

클래스는 다중 상속이 불가하다 

다음 그림이 예시인데 만약, 다음 그림처럼 다중 상속을 했을 때 자식 입장에서 move()를 실행하면 어떤 부모의 move()를 실행해야 하는지 자바는 알 수 없기 때문이다. 

 

 

인터페이스는 다중 상속이 가능하다

인터페이스는 여러 인터페이스를 상속받아도 결국 구현하는 구현체에서 그 쓰임새를 만들기 때문에 위와 같은 문제가 발생하지 않아서 그렇다. 

 

 

상속 관계에서 화살표의 방향은 '내가 얘를 알고 있다'라고 해석하면 이해가 빠르다

이 그림에서 자식에서 부모 방향으로 화살표 방향이 정해지는데, 가끔 이게 헷갈렸다. 근데 확실하게 이해했다. 내가 얘를 알고 있다로 보면 바로 이해된다. 자식은 부모를 상속받는다. 그래서 자식은 부모가 누군지 명확히 알고 있다. 근데 부모는 누가 나를 상속받는지 코드 상에서 전혀 알 수 없다. 그래서 화살표 방향을 저렇게 표현한다.

 

 

매우 중요한 부분: 상속과 메모리 구조

이 부분을 제대로 이해해야 한다. 다음 코드를 보자.

ElectricCar electricCar = new ElectricCar();

 

위와 같이 ElectricCar 클래스의 객체를 생성했다. 이 클래스는 Car를 상속받는다. 이럴 때 메모리에서 어떤 구조로 만들어질까?

그림과 같이 메모리에서는 ElectricCar 뿐만 아니라 상속 관계에 있는 Car까지 함께 포함된 인스턴스를 생성한다. 참조값은 x001 하나이지만 실제로 그 안에서는 Car, ElectricCar라는 두가지 클래스 정보가 공존하는 것이다. 

 

상속이라고 해서 단순하게 부모의 필드와 메서드만 물려 받는게 아니다. 상속 관계를 사용하면 부모 클래스도 함께 포함해서 생성된다. 외부에서 볼 땐 하나의 인스턴스를 생성하는 것 같지만 내부에서는 부모와 자식이 모두 생성되고 공간도 구분된다. 

 

이 상태에서 electricCar.charge()를 호출하면 어떻게 될까?

우선 참조값을 확인해서 x001.charge()를 호출하게 되고, x001을 찾아서 charge()를 호출하면 되는 건데 상속 관계의 경우엔 내부에 부모와 자식이 모두 존재한다. 이때 부모인 Car를 통해서 charge()를 찾을지 아니면 ElectricCar를 통해서 charge()를 찾을지 선택해야 한다. 이때는 호출하는 변수의 타입(클래스)을 기준으로 선택한다. electricCar 변수의 타입이 ElectricCar 이므로 인스턴스 내부에 같은 타입인 ElectricCar를 통해서 charge()를 호출한다.

 

이 내용을 토대로 electricCar.move()를 호출하면?

electricCar.move()를 호출하면 먼저 x001 참조로 이동한다. 내부에는 Car, ElectricCar 두가지 타입이 있다. 이때 호출하는 변수인 electricCar의 타입이 ElectricCar이므로 이 타입을 선택한다. 그러나 ElectricCar에는 move() 메서드가 없다. 상속 관계에서는 자식 타입에 해당 기능이 없으면 부모 타입으로 올라가서 찾는다. 이 경우 ElectricCar의 부모인 Car로 올라가서 move()를 찾는다. 부모인 Carmove()가 있으므로 부모에 있는 move() 메서드를 호출한다. 

만약, 부모에서도 해당 기능을 찾지 못하면 더 상위 부모에서 필요한 기능을 찾아본다. 부모에서 부모로 계속 올라가면서 필드나 메서드를 찾는 것이다. 물론 계속 찾아도 없으면 컴파일 오류가 발생한다.

 

 

정리를 하자면 딱 3가지 핵심을 기억하자.

  • 상속 관계의 객체를 생성하면 그 내부에는 부모와 자식이 모두 생성된다.
  • 상속 관계의 객체를 호출할 때, 대상 타입을 정해야한다. 이때 호출자의 타입을 통해 대상 타입을 찾는다. (electricCar.move()를 호출할 때 대상 타입은 electricCar라는 변수의 타입(위 예시에선 ElectricCar)을 찾아서 그 타입으로 먼저 찾는다는 의미)
  • 현재 타입에서 기능을 찾지 못하면 상위 부모 타입으로 기능을 찾아서 실행한다. 기능을 찾지 못하면 컴파일 오류가 발생한다.

 

 

오버라이딩과 메모리 구조

다를건 없다. 위에서 설명한 그대로를 따라가는데 한번 보자.

1. electricCar.move()를 호출한다.

2. 호출한 electricCar의 타입은 ElectricCar이다. 따라서 인스턴스 내부의 ElectricCar 타입에서 시작한다.

3. ElectricCar 타입에 move() 메서드가 있다. 해당 메서드를 실행한다. 이때 실행할 메서드를 이미 찾았으므로 부모 타입을 찾지 않는다.

 

메서드 오버라이딩 조건

- 메서드 이름: 메서드 이름이 같아야 한다.

- 메서드 매개변수(파라미터): 매개변수(파라미터) 타입, 순서, 개수가 같아야 한다.

- 반환 타입: 반환 타입이 같아야 한다. 단, 반환 타입이 하위 클래스 타입일 수 있다.

- 접근 제어자: 오버라이딩 메서드의 접근 제어자는 상위 클래스의 메서드보다 더 제한적이어서는 안된다. 예를 들어, 상위 클래스의 메서드가 protected로 선언되어 있으면 하위 클래스에서 이를 public, protected로 오버라이딩할 수 있지만, private, default로는 안된다.

- 예외: 오버라이딩 메서드는 상위 클래스의 메서드보다 더 많은 체크 예외를 throws로 선언할 수 없다. 하지만 더 적거나 같은 수의 예외, 또는 하위 타입의 예외는 선언할 수 없다.

- static, final, private 키워드가 붙은 메서드는 오버라이딩 될 수 없다: static은 클래스 레벨에서 작동하므로 인스턴스 레벨에서 사용하는 오버라이딩이 의미가 없고, final은 더이상 변경이 불가능함을 의미하고, private은 아예 자식 클래스에서는 보이지도 않는다.

- 생성자 오버라이딩은 불가하다.

 

 

상속과 접근제어

당연히 상속받아도 접근 제어자에 의해 접근이 가능하고 불가능하다. 단순하게 접근 제어자 그대로 따라가면 된다.

- public: 상속받은 클래스에서 접근할 수 있다.

- private: 상속받은 클래스에서 접근 불가하다.

- default: 같은 패키지의 상속받은 클래스면 접근이 가능하다.

- protected: 상속받은 클래스에서 접근이 가능하다.

 

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

다형성 (Part.1) 매우중요 ✨  (0) 2024.03.28
상속 (Part.2)  (0) 2024.03.28
final  (0) 2024.03.27
자바 메모리 구조 ✨  (0) 2024.03.27
캡슐화  (0) 2024.03.26
728x90
반응형
SMALL

final 키워드는 이름 그대로 끝! 이라는 뜻이다. 변수에 final이 붙으면 더이상 값을 변경할 수 없다. 정의를 모르는게 아니고 약간 헷갈릴 수 있는 부분들이나 알아두면 코드에 더 도움이 되는것들을 적어봤다.

 

다음 코드를 보자.

Final

public class Final {
    private final int value = 10;

    public Final(int value) {
        this.value = value;
    }
}

이 코드는 에러를 발생시킨다. 왜냐하면 'value'라는 변수에 final 키워드가 붙었고 선언과 동시에 초기화를 해줬다. 그럼 그 이후에 어디서도 이 값을 변경할 수 없는데 생성자에서 받은 값을 대입하려고 하고 있기 때문이다. 그래서 저 코드는 다음과 같이 수정할 수 있다.

public class Final {
    private final int value;

    public Final(int value) {
        this.value = value;
    }
}

value라는 변수를 final로 선언했지만 초기화하지 않은 경우 생성자를 통해서 초기화하든 어디서든 딱 한번은 초기화를 할 수 있다.

그럼 여기서 다음 코드를 보자.

 

Final

public class Final {
    private final int value = 10;

    public int getValue() {
        return value;
    }
}

 

FinalMain

public class FinalMain {
    public static void main(String[] args) {
        Final f = new Final();
        Final f2 = new Final();
        Final f3 = new Final();

        System.out.println("f = " + f.getValue());
        System.out.println("f2 = " + f2.getValue());
        System.out.println("f3 = " + f3.getValue());
    }
}

 

지금 이렇게 Final 이라는 클래스가 있고 그 클래스의 인스턴스 변수 valuefinal로 선언된 변수이다.

이 클래스의 인스턴스를 3개 만들고 해당 인스턴스가 가지는 value값을 출력해보면 다음 결과가 나온다.

실행결과:

f = 10
f2 = 10
f3 = 10

 

그리고 이 세개의 인스턴스도 마찬가지고 앞으로 생성될 모든 인스턴스 역시 value라는 값은 무조건 10일것이다.

그럼 인스턴스가 새로 생성될 때마다 힙 영역에 그 인스턴스가 올라가는데 메모리 낭비가 되지 않을까? 맞다. 그래서 이럴 때 사용하면 좋은 것이 바로 static이다. 

 

static final

왜냐하면 static으로 선언된 변수를 생각해보면 static 영역에 들어가고 오로지 한 개만 존재한다. 그리고 값은 변하지 않는다 왜냐? final로 선언했으니까.

이 말은 필드에 final + 필드 초기화를 사용하는 경우 static을 붙여서 사용하는 것이 효과적이다.

그리고 이렇게 static final로 선언하면 언제라도 변경되지 않는 유일한 수가 되는데 이를 Constant(상수)라고 한다.

 

자바에서는 상수를 선언할 때 관례가 있는데 모두 대문자를 사용하고 구분은 _로 한다. 다음 코드가 그 예시가 되겠다.

public class Final {
    public static final double PI = 3.14;
    public static final int MAX_USER = 10000;
}

 

 

참조형에 대한 final

그리고 또 한가지 헷갈리는 것은 참조형 변수에 final 키워드가 붙는 경우에는 해당 변수의 참조값을 더 이상 바꿀 수 없다는 것이지 참조하고 있는 인스턴스, 배열, 리스트 등등의 내부값은 변경이 가능하다.

 

난 이게 항상 헷갈렸는데 다음 코드를 보자.

 

Final

public class Final {
    private int value;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }
}

 

FinalMain

public class FinalMain {
    public static void main(String[] args) {
        final Final f = new Final();

        System.out.println(f.getValue());
        f.setValue(20);
        System.out.println(f.getValue());
    }
}

 

Final 클래스의 인스턴스 변수 valuefinal이 아니다. 그리고 main() 메서드에서 Final 인스턴스를 생성할 때 변수에 final이 붙었다.

그러면 당연히 초기화 한 이후로 f라는 변수는 다른 참조값을 가질 수 없고 딱 저 인스턴스가 가지는 참조값만 가질 수 있다.

그러나, f가 가지는 참조값을 쭉 따라가면 인스턴스가 나올것인데 그 인스턴스의 변수를 못바꾸는게 아니다. 그래서 실제로 실행결과는 다음과 같다.

 

실행결과:

0
20

 

다른 예시도 보자. 다음은 ArrayList()에 대한 참조값을 final로 받는 변수다. 이 변수에 당연히 다른 ArrayList() 참조값을 넣으려면 에러가난다. 다음이 그 화면인데 그렇다고 한들 이 list가 참조하는 참조값을 따라가서 List에 원소를 추가하고 빼는것에는 아무런 문제가 없다. (new로 선언하는 것은 참조형 = 힙 영역에 메모리를 차지 = 참조값을 참조 변수에 알려줌)

그저 참조값만 더 이상 변경이 불가능한 것이다. 다음을 보자.

public class FinalMain {
    public static void main(String[] args) {

        final List<Integer> list = new ArrayList<>();

        System.out.println(list.size());

        list.add(1);
        list.add(2);
        list.add(3);

        System.out.println(list.size());
    }
}

실제로 잘 추가가 되고 사이즈를 찍어보면 실행결과는 다음과 같다.

실행 결과:

0
3

 

 

클래스와 메서드에 final

클래스에 final이 붙으면 상속이 끝!

메서드에 final이 붙으면 오버라이딩 끝!

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

상속 (Part.2)  (0) 2024.03.28
상속 (Part.1)  (0) 2024.03.28
자바 메모리 구조 ✨  (0) 2024.03.27
캡슐화  (0) 2024.03.26
Class 레벨의 접근제어자  (0) 2024.03.26

+ Recent posts