무엇이든 담을 수 있는 제네릭(Generic) 프로그래밍

박윤택·2022년 5월 17일
3

JAVA

목록 보기
5/14

제네릭이란?

"클래스 내부에서 사용할 데이터 타입을 외부에서 파라미터 형태로 지정하면서 데이터 타입을 일반화한다." 즉, 클래스나 메서드에서 사용할 내부 데이터 타입을 컴파일 시에 미리 지정하는 방법이다

제네릭 없이 객체를 여러 자료형으로 받을 수 있도록 설계한다면 int, float, String 등의 여러 자료형에 대한 각각의 클래스를 하나씩 만들어야 하는 불편함이 있기 때문에 제네릭을 사용하면 클래스 하나만 생성하여 원하는 타입으로 받아 사용할 수 있다.

실제 사용되는 자료형의 반환은 컴파일러에 의해 검증된다. 그리고 컬렉션 프레임워크에서 많이 사용된다.

public class 클래스명<타입 매개변수> {...}
public interface 인터페이스명<타입 매개변수> {...}
[제네릭을 사용한 클래스 & 인터페이스]

타입 인자설명
TType
EElement
KKey
NNumber
VValue
RResult
[자주 사용하는 타입 매개변수]

필요성

java의 모든 클래스는 Object(최상위 클래스)를 상속받아 확장한다.


  • 1. 여러 종류의 커피 원두를 이용한 커피 머신 객체 만들기
public class CoffeeMachine {
	// 브라질산 커피
	private SantosBeans beans;
    
    public void setBeans(SantosBeans beans) {
    	this.beans = beans;
    }
    public SantosBeans getBeans() {
    	return beans;
    }
}

public class CoffeeMachine2 {
	// 콜롬비아산 커피
	private ColumbiaBeams beans;
    
    public void setBeans(ColumbiaBeams beans) {
    	this.beans = beans;
    }
    public ColumbiaBeams getBeans() {
    	return beans;
    }
}

여러 종류의 커피 원두를 이용해서 커피를 만드려고 할때 계속해서 각각의 원두를 커피 머신 클래스에 멤버로 할당해줘야 한다. 이 경우 Object 타입을 이용하여 하나의 커피 머신 클래스에 여러 원두를 멤버로 할당해줄 수 있다.


  • 2. 여러 종류의 커피 원두를 이용한 커피 머신 객체 만들기 - Object 이용
public class CoffeeMachine {
	private Object beans;
    
    public void setBeans(Object beans) {
    	this.beans = beans;
    }
    public Object getBeans() {
    	return beans;
    }
}

public class Main {
	public static void main(String[] args) {
    	CoffeeMachine coffeeMachine = new CoffeeMachine();
        SantosBeans santosBeans = new SantosBeans();
        
        coffeeMachine.setBeans(santosBeans);
        
        // getBeans()의 return type이 Object이기 때문에 형변환 필요!
        SantosBeans sb = (SantosBeans)coffeeMachine.getBeans();
    }
}

Object를 이용하여 여러 종류의 커피 원두를 커피 머신의 원두로 설정할 수 있지만 커피 머신이 사용하고 있는 원두를 가져오려고 할때는 Object 타입을 return 하고 있어 형변환이 반드시 필요하게 된다. 이에 따라 코드가 복잡해지고 잘못된 수동 타입 변환으로 인해 에러가 발생하기도 한다.


  • 3. 여러 종류의 커피 원두를 이용한 커피 머신 객체 만들기 - Generic 이용
public class CoffeeMachine<T> {
	private T beans;
    
    public void setBeans(T beans) {
    	this.beans = beans;
    }
    
    public T getBeans() {
    	return beans;
    }
}

public class Main {
	public static void main(String[] args) {
    	CoffeeMachine<SantosBeans> coffeeMachine = new CoffeeMachine<>();        
        coffeeMachine.setBeans(new SantosBeans());
        // getBeans()의 return type이 타입 매개변수이기 때문에 형변환 불필요!
        SantosBeans sb = coffeeMachine.getBeans();
    }
}

위와 같이 제네릭을 이용하면 코드가 간결해지고 수동으로 형변환을 하지 않아도 된다. 그런데 T(타입 매개변수)는 언제 해당 타입으로 바뀌는지 의문이 들 수 있다. 이는 setBeans() 함수를 이용해 커피 원두를 설정할때 T가 SantosBeans 참조 타입으로 변환이 된다.

제네릭의 장점을 정리하자면 다음과 같다.

  • 타입 체크와 형변환을 생략해서 코드가 간결해진다.
  • 클래스나 메서드 내부에서 사용되는 객체의 타입 안정성을 제공해준다.

제네릭 클래스

여러 참조 자료형을 사용해야 하는 경우에 Object가 아닌 하나의 문자로 표현해서 클래스에서 사용할 수 있다. 또한 하나의 타입 매개변수만 사용할 수 있는게 아니라 여러 개의 타입 매개변수를 사용할 수 있다. 그리고 class에서만 사용이 국한되지 않고 interface나 abstract, method에도 사용 가능하다.

접근 제어자 class 클래스명<T> {...} // 제네릭 타입 매개변수가 1개일 때
접근 제어자 class 클래스명<T, E> {...} // 제네릭 타입 매개변수가 2개일 때

눈에 잘 익지 않는 형태라 조금 이해하는데 어려울 수 있어 나름대로 뜻을 정리하자면 "해당 클래스에 어떠한 타입을 쓰겠다, 그 타입은 정해지지 않았고 컴파일 할때(추후에) 정하겠다."라고 받아들이면 좀 더 수월하게 이해되지 않을까 싶다.


<T extends 클래스>

extends 키워드를 이용하여 타입 매개변수의 범위를 제한할 수 있다. 또한 상위 클래스에서 선언하거나 정의하는 메서드를 활용(상속과 다형성)할 수 있으며, 다이아몬드 연산자 안에 타입 매개변수만 있다면 Object의 확장이 생략된 것이라고 볼 수 있다.

<T> == <T extends Object>

<T extends 클래스> : 클래스를 상속받는 하위 클래스만 사용 가능
<T super 클래스> : 사용 불가

  • <T extends 클래스> 사용 예

[원두의 상속 관계]

커피 머신을 이용해서 커피를 만드려면 원두 뿐만 아니라 물도 필요하다. 하지만 사용하는 원두에 따라 커피 머신을 관리하고자 한다고 가정한다면 물은 굳이 필요가 없게 된다.

  // 추상 클래스 Beans
public abstract class Beans {
    public abstract void doChange();
}
  
  // 브라질산 원두 - Beans 확장
public class SantosBeans extends Beans {
    @Override
    public void doChange() {
        System.out.println("브라질산 원두 교체");
    }

    public String toString() {
        return "브라질산 원두 입니다.";
    }

    public void santosana() {
        System.out.println("santosana");
    }
}
  
  // 콜롬비아산 원두 - Beans 확장
public class ColumbiaBeans extends Beans {
    @Override
    public void doChange() {
        System.out.println("콜롬비아산 원두 교체");
    }

    public String toString() {
        return "콜롬비아산 원두 입니다.";
    }

    public void columbiana() {
        System.out.println("columbiana");
    }
}
  
public class Water {
    public String toString() {
        return "물 입니다.";
    }

    public void doChange() {
        System.out.println("물 교체");
    }
}

public class CoffeeMachine<T extends Beans> {
    private T beans;

    public void setBeans(T beans) {
        this.beans = beans;
    }

    public T getBeans() {
        return beans;
    }

    public String toString() {
        // toString()은 Object 클래스에서 정의되어 있으나 overriding 하였음!
        return beans.toString();
    }

    public void change() {
        // 추상 클래스(Beans)에서 정의한 메서드 사용 가능!!
        beans.doChange();
    }

    public void info() {
        if(beans instanceof ColumbiaBeans) // 타입이 확실해지면 해당 클래스에 정의한 메서드 호출 가능!
            ((ColumbiaBeans) beans).columbiana();
        if(beans instanceof SantosBeans)
            ((SantosBeans) beans).santosana();
    }
}

public class Main {
    public static void main(String[] args) {
        CoffeeMachine<SantosBeans> santosCoffeeMachine = new CoffeeMachine<>();
        santosCoffeeMachine.setBeans(new SantosBeans());
        System.out.println(santosCoffeeMachine); // 브라질산 원두입니다.
        santosCoffeeMachine.change(); // 브라질산 원두 교체
        santosCoffeeMachine.info(); // santosana

        CoffeeMachine<ColumbiaBeans> columbiaCoffeeMachine = new CoffeeMachine<>();
        columbiaCoffeeMachine.setBeans(new ColumbiaBeans());
        ColumbiaBeans columbiaBeans = columbiaCoffeeMachine.getBeans(); // 형변환 필요 x
        System.out.println(columbiaBeans); // 콜롬비아산 원두 입니다.
        columbiaBeans.doChange(); // 콜롬비아산 원두 교체
        columbiaCoffeeMachine.info(); // columbiana
//        Type parameter 'generic.Water' is not within its bound; should extend 'generic.Beans'
//        CoffeeMachine<Water> waterCoffeeMachine = new CoffeeMachine<>(); // 에러 발생!

        // 타입 매개변수를 지정하지 않으면 Object를 반환
        CoffeeMachine coffeeMachine = new CoffeeMachine();
//        coffeeMachine.change(); // NullPointerException -> coffeeMachine 객체에 타입을 지정하지 않았기 때문
        coffeeMachine.setBeans(new SantosBeans());
        coffeeMachine.change(); // 브라질산 원두 교체
        coffeeMachine.info(); // santosana
    }
}

추상 클래스 Beans를 상속받은 SantosBeans와 ColumbiaBeans, 그리고 Water class를 CoffeeMachine 객체에 할당한 예제를 살펴보자.

  • 제네릭 클래스인 CoffeMachine에 <T extends Beans> 키워드가 붙어 있다. 이를 통해 Water를 제외한 두개의 class는 Beans를 상속(확장)받고 있어 Main class에서 CoffeeMachine 참조타입으로 사용이 가능하지만 Water는 사용하지 못한다.
  • 확장을 받은 Beans 추상 클래스에서 정의한 추상 메서드를 제네릭 클래스에서 사용 가능하다.
  • instanceOf를 이용해 타입 매개변수가 정해진다면 제네릭 클래스에서 타입 매개변수로 받는 클래스에서 정의한(Overriding 되지 않은) 메서드를 사용할 수 있다.
  • 타입 매개변수를 지정하지 않으면 Object를 반환한다.

그렇다면 Beans class를 interface로 선언하고 CoffeeMachine class에서 참조 매개변수를 <T implements Beans>로 설정할 수 있을까?

불가능하다! 이유는 implement(구현하다), extend(확장하다)의 뜻에서 알 수 있듯이 확장을하는 개념이지 구현을 하는 개념이 아니기 때문이다.

와일드 카드

? 키워드를 이용하여 와일드카드를 사용할 수 있다. ?의 의미는 알 수 없는 타입을 의미한다.

<?> // Unbounded Wildcards : 타입 매개변수에 모든 타입 사용
<? extends T> // Lower Bounded Wildcards : T타입과 T타입을 상속받는 모든 하위 클래스 타입만 사용
<? super T> // Upper Bounded Wildcards : T타입과 T타입을 상속받은 상위 클래스 타입만 사용

그렇다면 제네릭 타입 매개변수랑 차이가 뭘까? "타입"보다 타입 매개변수를 사용하는 "방법"이 더 중요할때 사용한다.

public <T extends Number> void printList(List<? extends T> list) {
	for(Number num : list)
		System.out.println(num);
}

제네릭 메서드

"타입 매개변수를 메서드의 매개변수나 반환 값으로 가지는 메서드"로 제네릭 클래스와 같이 타입 매개변수가 하나 이상인 경우도 있다.

public class TestClass1<T> {
	private T test;

	// 제네릭 메서드
	public void setTest(T test) {
		this.test = test;
	}

	// 제네릭 메서드
	public T getTest() {
		return test;
	}
}

public class TestClass2 { // 일반 클래스 내부에 제네릭 메서드 선언
	// 제네릭 메서드
	public <T> T accept(T t){ // 리턴 타입 앞에 사용할 타입 매개변수 선언
		return t;
	}
	// 제네릭 메서드
	public <K, V> void getPrint(K k, V v) { // 리턴 타입 앞에 사용할 타입 매개변수 선언
		System.out.println(k + " : " + v);
	}
}

public class Main {
	public static void main(String[] args) {
		TestClass2 testClass2 = new TestClass2();
		String str1 = testClass2.<String>accept("test");
		// 입력 매개변수 값으로 제네릭 타입이 추론 가능하면 생략 가능
		String str2 = testClass2.accept("test");
		System.out.println(str1); // test
		System.out.println(str2); // test

		testClass2.<String, Integer>getPrint("test", 1); // test : 1
		// 입력 매개변수 값으로 제네릭 타입이 추론 가능하면 생략 가능
		testClass2.getPrint("test", 1); // test : 1
	}
}

여기서 주의해야할 점은 컴파일 시점에서 타입 매개변수의 타입이 정해지기때문에 어떤 타입이 입력되는지 알 수 없다. 그래서 만약 String 타입이 제네릭 메서드의 매개변수로 들어온다면 .length()와 같은 String 클래스의 메서드를 사용할 수 없고 최상위 클래스인 Object의 메서드만 사용이 가능하다.

0개의 댓글