14주차 과제 : 제네릭

Lee·2021년 2월 26일
0
post-thumbnail

제네릭(Generics)

자바에서 제공하는 제네릭에 대해 학습해보자 📖

  • 제네릭 사용법
  • 제네릭 주요 개념 (바운디드 타입, 와일드 카드)
  • 제네릭 메소드 만들기
  • Erasure

제네릭(Generics)이란?

  • 컴파일시 타입을 체크해 주는 기능(compile-time type check) - JDK 1.5 부터 추가
ArrayList<Hamburger> tvList = new ArrayList<Tv>();

tvList.add(new Hamburger()); // OK
tvList.add(new Drink()); // 컴파일 에러, Tv 외에는 다른 타입은 저장이 불가능하다.

기존에 ArrayList는 Object 배열을 가지고 있기 때문에 모든 종류의 객체가 저장이 가능하다. 만약 특정 객체(Hamburger)만 저장하고 싶다는 가정하에 제네릭이 존재하기 전에 특정 객체 외에 다른 객체가 저장이 되도 잡을 수 있는 방법이 없었다. 하지만 제네릭이 도입되면서 List에 타입을 한번 설정하고 객체를 생성하면 생성 시 설정한 객체 외에는 컴파일러가 막아주는 역할을 수행한다.

제네릭 사용 전

public class GenericsTest {
    public static void main(String[] args) {
        ArrayList list = new ArrayList();
        list.add(10);
        list.add(20);
        list.add("Test");

        System.out.println(list);
    }
}
결과 💡
[10, 20, Test]

기본적으로 Object 배열이기 때문에 어떤 타입이 올 수 있다. 또한 중간에 형변환하는 과정에 있어서 런타임 오류가 발생할 가능성이 높다.

public class GenericsTest {
    public static void main(String[] args) {
        ArrayList list = new ArrayList();
        list.add(10);
        list.add(20);
        list.add("30"); 

        System.out.println((Integer)list.get(2)); //마지막으로 저장된 문자열 30을 int형으로 형변환 할려고 함
    }
}
결과 💡
Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer
	at com.lee.company.GenericsTest.main(GenericsTest.java:12)

제네릭 사용 후

public class GenericsTest {
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList();
        list.add(10);
        list.add(20);
        list.add("Test");
				
        // 이미 제네렉을 통해 String 타입으로 선언했기 때문에 형변환 필요가 없다.
      	System.out.println((Integer)list.get(2)); 
        System.out.println(list);
    }
}

컴파일조차 되지도 않을 뿐더라 IDE에서 오류로 표시해준다. 이를 통해 제네릭을 이용하면 타입의 대한 안정성 하나는 보장받을 수 있고, 귀찮은 형변환의 번거로움을 줄일 수 있다는 사실을 얻었다 💡

결국 중요한게 뭘까?

잠깐 공부했지만, 개인적인 생각으로썬 개발자라면 누구나 오류를 보게된다. 하지만 그 오류도 컴파일 에러이냐, 런타임 에러이냐에 따라 처리하는 속도가 달라진다. 아마 잠재적으로 치명적인 오류를 발생시킬 수 있는 런타임 에러보단, 애초당시에 컴파일 에러를 발생시키는 것이 훨씬 좋은 방법이라고 생각하기 때문에 이러한 기능이 추가되지 않았다 싶다.

// 추가 설명이 필요한 듯

컴파일 타임에 타입 체크를 하기 때문에 런타임에서 ClassCastException 같은 예외로부터 안정성을 보장받을 수 있다.

타입변수

  • 클래스를 작성할 때, Object 대신 타입 변수(E)를 선언해서 사용
  • 막연하게 Object 타입으로 사용되는 것이 아닌 개발자가 설정한 타입으로 설정되는 것
ArrayList<String> list = new ArrayList(); // <String>이 타입변수에 지정됨
  • 객체 생성시 , 타입 변수(E) 대신 실제 타입을 지정해 대입
ArrayList<E> list = new ArrayList<E>();
ArrayList<String> list = new ArrayList<String>(); 
ArrayList<Hamburger> list = new ArrayList<Hambuger>(); // 사용자 지정 클래스가 올 수 있음
  • 타입 변수에 실제 타입이 지정되지 않으면, 위에서 언급한 것 처럼 추후에 형변환이 필요하다.

자주 사용하는 타입변수

타입인자설명
Type
Element
Key
Number
Value
Result

제네릭 생성 하기

클래스를 설계할 때 구체적인타입을 명시하지 않고 타입 파라미터로 넣어두었다가 실제 해당 클래스를 인스턴스화 시킬 때 구체적인 타입을 지정하면 제네릭 타입의 클래스를 생성할 수 있다.

  • 제네릭 클래스 생성
public class SampleGenericsDemo<T> {

    private T t;

    public T getT() {
        return t;
    }

    public void setT(T t) {
        this.t = t;
    }
}
  • 제네릭 클래스의 활용
public class GenericsTest {
    public static void main(String[] args) {
        SampleGenericsDemo<String> demo = new SampleGenericsDemo<>();
        demo.setT("Test");

        System.out.println(demo.getT());
    }
}
결과 💡
  
Test

제네릭으로 클래스를 생성할 수 있을 뿐만 아니라 인터페이스도 설계가 가능하다

  • 제네릭 인터페이스 생성
public interface GenericInterfaceDemo<T> {
    T testMethod();
}
  • 제네릭 인터페이스를 직접 구현하기
public class GenericsTest implements GenericInterfaceDemo<String> {
    public static void main(String[] args) {
        System.out.println(new GenericsTest().testMethod());
    }

    @Override
    public String testMethod() {
        return "제네릭 인터페이스로 구현한 메소드입니다.";
    }
}
결과 💡
제네릭 인터페이스로 구현한 메소드입니다.
  • 멀티 타입 파라미터 생성
  • 각 타입 파라미터를 콤마(,)로 구분하고, 생성 방법은 위에서 단일 제네릭으로 생성하는 법과 동일하다.
public class MultiTypeGeneric<K, V> {

    private K key;
    private V value;

    public K getKey() {
        return key;
    }

    public void setKey(K key) {
        this.key = key;
    }

    public V getValue() {
        return value;
    }

    public void setValue(V value) {
        this.value = value;
    }
}
  • 멀티 타입 파라미터 사용
public class GenericsTest {
    public static void main(String[] args) {
        MultiTypeGeneric<String, Integer> demo = new MultiTypeGeneric<>();

        demo.setKey("TestKey");
        demo.setValue(1234);

        System.out.println("Key : " + demo.getKey() + ", Value : " + demo.getValue());
    }
}
결과 💡
Key : TestKey, Value : 1234

제한된 제네릭 클래스

  • extends로 대입할 수 있는 타입을 제한
class FruitBox<T extends Fruit> { // Fruit 클래스 포함한 자식 타입으로만 지정가능
  	ArrayList<T> list = new ArrayList<>();
}

만약 Apple 이라는 클래스가 Fruit의 자식클래스라고 가정하에 제네릭 클래스를 사용한다면

FruitBox<Apple> appleBox = new FruitBox<Apple>(); // Apple은 Friut의 자식이기 때문에 성공!
FruitBox<Toy> toyBox = new FruitBox<Toy>(); // Toy는 Fruit의 자식이 아니기 때문에 에러 발생!
  • 인터페이스인 경우에도 extends를 사용
interface Eatable{}
class FruitBox<T extends Eatable> {}

제네릭의 제약

  • 타입 변수에 대입은 인스턴스가 생성되는 시점에 결정되기 때문에 static 멤버에 타입 변수는 사용이 불가능하다.
Box<Apple> appleBox = new Box<Apple>();
class Box<T> {
		static T item; // 에러
  	static int compare(T t1, T t2) {...} // 에러
}
  • 배열 생성 시, 타입 변수가 사용이 불가능하다, 단 선언은 가능
class Box<T> {
  	T[] itemArr;
  
 	  T[] toArray() {
    	T[] tmpArr = new T[10]; // new 연산자 뒤에는 타입 변수가 올 수 없다.
  	}
}

와일드 카드

  • 하나의 참조 변수로 대입된 타입이 다른 객체를 참조 가능
: 와일드 카드의 상한 제한. T와 그 자손들만 가능 제한 없음. 모든 타입이 가능. 와 동일
  • 메소드의 매개변수에 와일드 카드를 사용
public BeefHamburger makeHamBurger(PattyBox<? extends Beef> patty) {
  ...
  // Beef 클래스를 상속받는 TenToOneBeef, FourToOneBeef 둘 다 들어올 수 있다.
}
System.out.println(Hamburger.makeHamburger(new PattyBox<TenToOneBeef>)); // 10 : 1 패티
System.out.println(Hamburger.makeHamburger(new PattyBox<FourToOneBeef>)); // 4 : 1 패티

제네릭 메소드

  • 메소드 선언부에 제네릭 타입이 선언된 메소드를 제네릭 메소드라고 부른다. (타입 변수는 메소드 내에서만 유효)
static <T> void sort(List<T> list, Comparator<? super T> c)
  • 클래스의 타입 매개변수와 메소드의 타입 매개변수 는 별개
class FruitBox<T> {
  	...
  	static <T> void sort(List<T> list, Comparator<? super T> c) {
      ...
    }
}

클래스의 타입 매개변수가 범위적으로 봐도 훨씬 크지만 우선순위는 제네릭 메소드의 타입 매개변수가 더 우선시 된다.

  • 메소드를 호출할 때마다 타입을 대입해야 한다.
static <T extends Fruit> Juice makeJuice(FruitBox<T> box) {
 		String temp = "";
  	for (Fruit fruit : box.getList()) {
      	temp += fruit + " ";
    }
  	return new Juice(temp);
}

제네릭 메소드는 메소드를 호출할 때마다 다른 제네릭 타입을 대입할 수 있게 한 것

와일드 카드는 하나의 참조변수로 서로 다른 타입이 대입된 여러 제네릭 객체를 다루기 위한 것

제네릭 타입의 형변환

여기서부턴 자바 라이브 스티디원 중 한명인 ssonsh 님의 글을 많이 참고하여 작성하였습니다.

  • 제네릭 타입과 원시 타입 간의 형변환은 바람직 하지 않다.
Box<Object> objBox = null;
Box box = (Box)objBox; // 제네릭 타입 -> 원시 타입. 경고 발생
objBox = (Box<Object)box; // 원시 타입 -> 제네릭 타입. 경고 발생

위 코드는 컴파일 상 오류는 발생하지 않지만, 다만 경고가 발생한다. 이를 통해 제네릭 타입과 제네릭 타입이 아닌 타입간의 형변환은 자유롭지만, 경고가 발생한다라는 사실을 알 수 있다.

대입된 타입이 다른 제네릭 타입 간의 형변환은 가능할까?

Box<Object> objBox = null;
Box<String> strBox = null;

objBox = (Box<Object>)strBox;
strBox = (Box<String>)objBox;

대입된 타입이 Object 타입일지라도 에러가 발생한다.

와일드 카드가 사용된 제네릭 형태로 형변환을 할 수 있을까?

Box<? extends Object>에 대입될 수 있는 타입은 여러개
Box<String> 을 제외한 다른 타입은 Box<String>으로 형변환 될 수 없기 때문이다.

형변환은 가능하긴 하지만, 와일드 카드는 타입이 확정된 타입이 아니므로 컴파일러는 미확정 타입으로 형변환하는 것이라고 경고를 보내준다.

제네릭의 타입 제거 (Erasure)

컴파일러는 제네릭 타입을 이용해 소스파일을 체크하고, 필요한 곳에 형변환을 넣어준다. 그리고 제네릭 타입을 제거한다. 즉, 컴파일된 파일(*.class)에는 제네릭 타입에 대한 정보가 없는 것이다.

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

        Box<String> strBox = new Box<>();

        strBox.setItem("string box!");
    }
}
  • 이렇게 처리되는 이유는 제네릭이 도입되기 이전의 소스코드와 호환성을 유지하기 위해서이다.
  • JDK 1.5부터 제네릭이 도입되었지만, 아직도 원시 타입을 사용해서 코드를 작성하는 것을 허용한다.
    • 언젠가 새로운 기능을 위해 하위 호환성을 포기하기 될 때가 올 것이다.

이렇게 하위호환성을 유지해야 함으로 원시타입 지원 + 제네릭을 구현할 때 소거(erasure) 방식을 이용했다.

제네릭 타입의 제거과정

  • 제네릭 타입의 경계(bound)를 제거한다.
  • 제네릭 타입이 라면 T는 Fruit로 치환된다.
  • 인 경우는 T는 Object로 치환된다.
  • Object로 변경하는 경우 unbounded 된 경우를 뜻한다.
AS-IS
class Box<T extends Fruit>{
	void add(T t){
		...
	}
}
TO-BE
class Box{
	void add(Fruit t){
		...
	}
}
  • 제네릭 타입을 제거한 후에 타입이 일치하지 않으면, 형변환을 추가한다.
  • List의 get()은 Object 타입을 반환하므로 형변환이 필요하다.
AS-IS
T get(int i){
	return list.get(i);
}
TO-BE
Fruit get(int i){
	return (Fruit)list.get(i);
}
  • 와일드 카드가 포함되어 있는 경우 다음과 같이 적절한 타입으로의 형변환이 추가된다.
AS-IS
static Juice makeJuice(FruitBox<? extends Fruit> box){
	String tmp = "";
	for(Fruit f : box.getList()) temp += f + " ";
	return new Juice(temp);
}
TO-BE
static Juice makeJuice(FruitBox box){
	String tmp = "";
	Iterator it = box.getList().iterator();
	while(it.hasNext()){
		tmp += (Fruit)it.next() + " " ;
	}
	return new Juice(temp);
}

확장된 제네릭 타입에서 다형성을 보존하기 위해 Bridge Method를 생성하기도 한다.

  • Java Compiler는 제네릭 타입의 안정성을 위해 Bridge Method도 만들어 낼 수 있다.
  • Bridge Method는 Java Compiler가 컴파일 할 때 메소드 시그니처가 조금 다르거나 애매할 경우 대비해 작성된 메소드이다.
  • 이 경우는 파라미터회된 클래스나 인터페이스를 확장한 클래스를 컴파일할 때 생길 수 있다.

참고자료 🧾

0개의 댓글