[F-Lab] 모각코 챌린지 10일차

tree·2023년 5월 11일
0

배열의 타입

  • 자바의 배열 타입은 다른 클래스들처럼 명시적으로 클래스 파일이 존재하지 않는다. 자바 언어의 특수적인 기능에 의해 동적으로 객체가 생성되는 것이다.
    • 배열은 클래스가 아니다. 동적으로 생성되는 객체다.
    • jls-10

Array types are not classes, but array instances are objects. This means that arrays inherit the methods of java.lang.Object. -Java in a nutshell

public class Main {
    public static void main(String[] args) {
        int[] arr1 = new int[10];
        String[] arr2 = new String[10];
    }
}
  • 위와 같은 코드가 있을 때 int[], String[] 클래스의 코드가 어딘가에 작성되어 있지 않다.

배열의 클래스 계층 구조

  • 그렇다면 배열의 타입은 클래스 계층 구조 상에서 어디에 위치할까?

    Because arrays extend Object and implement the Cloneable and Serializable interfaces, any array type can be widened to any of these three types.
    -Java in a nutshell

    • 배열은 Object 클래스를 상속하고 Cloneable과 Serializable을 구현하고 있다고 한다. 코드로 확인해 보자.

      public class Main {
          public static void main(String[] args) {
              int[] arr = new int[10];
      
              System.out.println(arr.getClass().getSuperclass());
      
              Class<?>[] interfaces = arr.getClass().getInterfaces();
              for (Class<?> i : interfaces) {
                  System.out.println(i);
              }
          }
      }
      • 위의 코드를 실행해보면 아래와 같은 결과를 얻을 수 있다.
      class java.lang.Object
      interface java.lang.Cloneable
      interface java.io.Serializable
      
      Process finished with exit code 0
  • 그럼 상속관계를 가지는 클래스의 배열 타입의 상속 관계는 어떠할까?

    public class Main {
        public static void main(String[] args) {
            SoccerPlayer[] arr = new SoccerPlayer[10];
    
            System.out.println(arr.getClass().getSuperclass());
    
            for (Class<?> i : arr.getClass().getInterfaces()) {
                System.out.println(i);
            }
        }
    }
    class Player {}
    class SoccerPlayer extends Player {}
    • 위의 코드를 실행해보면 아래와 같은 결과를 얻을 수 있다.

      class java.lang.Object
      interface java.lang.Cloneable
      interface java.io.Serializable
      
      Process finished with exit code 0
    • Player 클래스와 SoccerPlayer는 상속 관계에 있지만 Player[]과 SoccerPlayer[]는 직접적인 상속 관계에 있다고 나와있지 않다.

getSuperclass() 메소드 설명 중 마지막 줄을 봐도 객체가 배열 타입이면 Object 클래스를 나타하는 Class 객체를 리턴한다고 되어있다. jls-10.8에서도 모든 배열 타입의 Class 객체는 직접적인 상위 클래스가 Object 클래스인 것처럼 동작한다고 나타나 있다.

배열은 클래스가 아니라 동적으로 생성되는 객체이기 때문에 클래스 계층 구조 상에서 존재하지 않는다.

하지만 배열 타입 간의 형변환을 하는 코드를 자주 보았던 기억이 있을 것이다.

public class Main {
   public static void main(String[] args) {
       Number[] arr = (Number[]) new Integer[10]; 
   }
}

위의 코드를 실행하면 아무런 문제를 일으키지 않고 잘 실행이 된다.

배열의 형변환

  • jls-4.10.3을 보면 배열 타입 간의 형변환 규칙을 설명해주고 있다.
    • S가 T의 상위 타입이면 S[]가 T[]의 상위 타입이다.
    • Object는 Object[]의 상위 타입
    • Cloneable는 Object[]의 상위 타입
    • java.io.Serializable는 Object[]의 상위 타입
    • P가 기본형인 경우
      • Object는 P[]의 상위 타입
      • Cloneable는 P[]의 상위 타입
      • java.io.Serializable는 P[]의 상위 타입

배열과 공변

공변이란 함께 변한다는 의미인데 배열의 형변환 규칙 중 "S가 T의 상위 타입이면 S[]가 T[]의 상위 타입이다." 라는 규칙은 배열이 공변이라는 것을 의미한다.

배열이 공변의 특성에 더해 실체화(reified, 런타임 시 배열의 타입과 런타임 시 요소의 타입이 동일한지 체크)하는 특성이 있기 때문에 사용 시에 주의해야 한다.

public class Main {
    public static void main(String[] args) {
        Object[] arr = (Object[]) new Integer[10];

        arr[1] = "Hello";
    }
}

Object가 Integer의 상위 타입이므로 Object[] 역시 Integer[]의 상위 타입이다. 따라서 Integer[] 객체를 Object[]로 형변환하여 arr에 대입하는 것은 아무런 문제가 없다.

하지만 배열에 "Hello" 문자열 객체를 대입하는 것은 문제가 있다. 배열은 런타임 시 배열의 타입과 요소의 타입이 동일한지 확인하는데 arr의 실제 타입은 Integer[]이지만 String 타입인 "Hello"를 배열의 요소로 저장하려 하기 때문에 문제가 발생한다.

하지만 위의 코드를 컴파일 해도 컴파일이 잘 된다.

하지만 코드를 실행하면 문제가 발생한다.

Exception in thread "main" java.lang.ArrayStoreException: java.lang.String
    at Main.main(Main.java:5)

Process finished with exit code 1

코드를 실행하면 그제서야 ArrayStoreException을 발생시키며 프로그램을 종료시킨다.

컴파일 시점까지는 아무런 문제를 일으키지 않다가 실행 시점에 문제를 일으킨다. 컴파일 에러가 런타임 에러보다 좋다는 것은 누구나 알 것이다.

배열의 공변과 구체적인 특성은 개발자의 부주의에 의해 문제를 일으키기 딱 좋은 상황을 만들어낼 수 있다는 말이다.

그럼 이런 생각이 들 것이다. 위의 코드처럼 Integer[] 배열을 Object[] 배열로 형변환하여도 배열에 String 객체를 저장하지 못하는데 왜 배열을 공변으로 만들어 둔 것일까?

배열이 공변인 이유

언뜻 보기에는 배열이 공변일 필요가 없어 보이지만 잘 활용하면 리스코프 치환 원칙을 지키는 객체지향적인 코드를 만들 수 있다.

예시를 보면서 알아보자. Arrays 클래스를 보면 내부에 swap() 메소드가 있다.

private static void swap(Object[] x, int a, int b) {
    Object t = x[a];
    x[a] = x[b];
    x[b] = t;
}

swap()는 배열의 요소를 서로 바꾸는 static 메소드다.

배열이 공변의 특성을 가지지 않는다고 가정해보자. 즉 배열이 불공변이라고 가정해보자. 그럼 Object가 Integer의 상위 타입이라고 하더라도 Object[]는 Integer[]의 상위 타입이 아니게 되고 Integer[] 객체를 Object[]로 형변환 할 수 없을 것이다.

그럼 위의 swap() 메소드를 호출할 때 메소드의 인자로 Integer[] 객체를 전달하는 것이 가능할까? 불가능하다. Integer[]를 인자로 받는 swap() 메소드를 별도로 오버로드해야 할 것이다.

하지만 배열이 공변의 특성을 가짐으로써 메소드를 여러 개 오버로드 할 필요 없이 하나의 메소드만으로 다양한 타입의 배열을 인자로 전달받아 사용할 수가 있는 것이다. 즉, 리스코프 치환 원칙을 잘 지키고, 재사용성이 높은, 객체지향적인 코드를 만들 수 있다는 것이다.

제네릭과 불공변

배열은 공변인 반면에 제네릭은 불공변이다. 즉, Object가 Integer의 상위 타입이더라도 ArrayList<Object>ArrayList<Integer>의 상위 타입이 아니라는 말이다.

코드를 통해 확인해보자.

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        ArrayList<Object> list = new ArrayList<Integer>();

        list.add("Hello");
    }
}

위의 코드를 컴파일 하면 컴파일 에러를 발생시키며 컴파일이 되지 않을 것이다. 그 이유는 제네릭은 불공변이고 컴파일 시점에 타입 체크를 수행하기 때문이다. 따라서 컴파일 시점에 타입 불일치를 잡아주어 런타임 에러에 노출되는 위험을 방지할 수 있다.

하지만 불공변이기 때문에 배열처럼 리스코프 치환 원칙을 잘 지키는 코드를 작성하기 어려운 문제가 있다.

class Box<T> {
    public void add(Box<T> box) {}
}

public class Main {
    public static void main(String[] args) {
        Box<Number> box = new Box<>();
        box.add(new Box<Integer>());
    }
}

위의 코드를 컴파일 하면 컴파일 에러가 발생할 것이다.

왜냐하면 Box<Number>Box<Integer>는 상위, 하위 타입 관계가 아니기 때문이다. 이를 해결하기 위해서는 어떻게 해야 할까?

와일드 카드를 활용하면 된다(모르면 자바 기본서를 다시 보자). 위의 코드를 와일드 카드를 사용해 수정해보자.

class Box<T> {
    public void add(Box<? extends T> box) {}
}

public class Main {
    public static void main(String[] args) {
        Box<Number> box = new Box<>();
        box.add(new Box<Integer>());
    }
}

수정한 코드를 실행해 보면 아마 문제 없이 잘 실행될 것이다.

이처럼 제네릭은 불공변이기 때문에 리스코프 치환 원칙을 지키는 코드를 작성하기 어렵다고 생각할 수 있지만 와일드 카드를 잘 활용하면 얼마든지 리스코프 원칙을 잘 지키는 코드를 작성할 수 있다.

반공변

추가로 반공변에 대해서 알아보자.
반공변은 S가 T의 상위 타입이면 C<T>C<S>의 상위 타입이라는 개념이다.

코드로 알아보자.

class Box<T> {
    public void add(Box<? super T> box) {}
}

public class Main {
    public static void main(String[] args) {
        Box<Integer> box = new Box<>();
        box.add(new Box<Number>());
    }
}

위의 코드가 반공변을 보여주는 코드이다.

Number가 Integer의 상위 타입이므로 Box<Integer>Box<Number>의 상위 타입이다.

요약

  • 배열은 클래스가 아니다. 동적으로 생성되는, Object를 상속하는 객체이다.
    • 배열 객체 역시 Object 클래스에 선언된 메소드를 호출할 수 있다.
    • 배열은 클래스 계층 구조 상에서 존재하지 않는다.
의미
공변(variant)S가 T의 상위 타입이면 C<S>C<T>의 상위 타입니다.
불공변(invariant)S가 T의 상위 타입이면 C<S>C<T>의 상위 타입니다.
반공변(contravariant)S가 T의 상위 타입이면 C<T>C<S>의 상위 타입니다.
  • 배열은 공변이다.
    • S가 T의 상위 타입이면 S[] 역시 T[]의 상위타입이다.
    • 장점
      • 리스코프 치환 원칙을 활용해 재사용성이 높은 코드 작성이 가능하다.
    • 단점
      • 배열이 공변 + 실체화(런타임 시에도 배열 객체 타입과 요소 타입을 체크)라는 특성을 가지므로 런타임 에러에 노출될 수 있다.
  • 제네릭은 불공변이다.
    • Object가 Integer의 상위 타입이더라도 ArrayList<Object>ArrayList<Integer>의 상위 타입이 아니다.
    • 장점
      • 컴파일 시점에 타입을 체크하여 런타임 에러를 방지한다.
    • 단점
      • 리스코프 치환원칙을 활용한 코드 작성이 어렵다.
        • 보완책
          • 제네릭은 불공변이지만 와일드 카드는 공변, 반공변이므로 잘 활용하면 리스코프 치환 원칙을 지킬 수 있다.

0개의 댓글