세상에 나쁜 코드는 없다

Generic Programming 본문

Computer Science/Java

Generic Programming

Beomseok Seo 2024. 4. 10. 15:59

Generic Programming이란?

Generic programming centers around the idea of abstracting from concrete, efficient algorithms to obtain generic algorithms that can be combined with different data representations to produce a wide variety of useful software.

제너릭 프로그래밍이란 구체적인 알고리즘, 로직, 혹은 행동을 서로 다른 데이터 타입에 대해 적용하기 위해 하나의 일반화된 알고리즘, 로직, 혹은 행동으로 추상화하는 프로그래밍 방법입니다.

이는 추상화의 한 방법이기에, 소프트웨어 문제를 어떻게 추상화하고자 하는가에 따라 같은 코드라도 다른 방법이 채택될 수 있습니다. 만약 Student, Lecturer, Faculty라는 개념이 있고, 이들이 모두 등록될 수 있어야 한다면, 이를 객체들이 가져야할 일종의 계약으로 보고 구현을 강제하자는 측면에서는 인터페이스를 사용할 수 있을 것이며, 등록 로직은 동일하니 여러 타입을 받도록 하자는 측면에서는 제너릭 메서드를 사용할 수 있을 것입니다.

리마인드 용 Build Process

우리가 작성한 소스코드는 다음과 같은 과정을 거쳐 수행됩니다.

  1. 컴파일 - 사람이 읽을 수 있는 소스코드를 기계어로 변환하는 과정입니다.
  2. 링킹 - 컴파일된 파일들을 엮어 하나의 실행가능한 파일로 만드는 과정입니다.
  3. 로딩 - 링킹이 완료된 실행가능한 파일을 메모리에 적재하는 과정입니다.

이 때의 컴파일과 링킹 과정, 즉 하나의 실행가능한 파일로 만드는 과정을 빌드라고 부르기도 합니다. 빌드 시기는 프로그램이 수행중이 아니므로 컴파일 타임이라 부르며, 로딩 이후 프로그램이 실행중인 시기를 런타임이라 부릅니다.

C++ template

C++에서 제너릭 프로그래밍은 template이라는 개념으로 도입되었습니다. 흔히 c++로 알고리즘 문제를 풀때 사용하는 STL은 Standard Template Library의 약자로, 이러한 제너릭 프로그래밍의 개념이 사용되었음을 알 수 있습니다. STL은 여러 잘 만들어진 자료구조 클래스를 제공하고, 이들을 사용하는 시점에 타입을 지정하여 어떠한 타입이든 자료구조를 이용할 수 있도록 합니다.

// [코드 1] c++ template 예시
template <class T>
class Calculator
{
private:
    T num1;
    T num2;
public:
    Calcu(T num1, T num2) {
        this->num1 = num1;
        this->num2 = num2;
    }
    T GetAdd() {
        return num1 + num2;
    }
};

int main()
{
	// 아래는 자바에서는 볼 수 없는 C++만의 생성자입니다.
    Calculator<int> calcu1(10, 20);
    cout << calcu1.GetAdd() << endl;
    
    Calculator<double> calcu2(10.52, 20.24);
    cout << calcu2.GetAdd() << endl;
}

C++에서는 템플릿을 위와 같이 사용합니다. template <class T>라는 코드를 통해 이 클래스가 제너릭 클래스임을 표현하며, T 제너릭 타입을 통해 클래스 내부에서 일반화된 타입을 사용합니다.

클래스 템플릿을 사용하기 위해서는 Calculator<int> calcu; 와 같이 클래스를 생성하는 시점에 타입을 지정해줘야 합니다. 이후 해당 클래스는 해당 타입만을 받고 로직을 수행하게 됩니다.

Compile-time Type generalization

C++ 의 template은 다음과 같은 특징을 갖습니다.

  1. 선언과 정의는 뭉뚱그려서 하지만, 사용될 때는 타입을 정의하여 사용해야 한다.
    로직을 추상화하기 위하여 T라는 제너릭 타입을 사용하지만, 이 클래스가 실제로 사용되는 시점에서는 타입이 지정되어야 합니다.

  2. 컴파일 타임 함수화
    C++ 컴파일러는 컴파일하는 도중 제너릭 클래스가 사용된 곳을 기계어로 번역할 때 기술된 타입을 처리할 수 있는 실행가능한 명령어 묶음을 생성합니다. 이 실행가능한 명령어 묶음은 제너릭 클래스의 로직에 기술된 데이터 타입이 적용되어있는 모습입니다. [코드 2]

    이제는 컴파일러를 통해 해당 타입을 처리할 수 있는 클래스가 만들어졌으므로 원래의 제너릭 코드가 있던 곳은 이 코드를 호출하도록 컴파일됩니다. 이러한 분화Specialization은 컴파일 타임에 발생합니다.

  3. 템플릿의 사용은 프로그램을 느려지게 만들지 않는다.
    분화되는 시점은 컴파일 타임이므로 런타임의 시간복잡도와는 상관이 없습니다. 컴파일 이후 런타임에는 T라는 일반화된 타입정보는 프로그램의 그 어디에도 남지 않고 오직 구체적인 타입을 받는 클래스만이 남습니다.

  4. Code Explosion
    만약 Calculator<int> 뿐만 아니라 Calculator<double>, Calculator<MyClass> 도 프로그램 내에서 사용된다면 각각의 타입을 처리하는 코드가 생성됩니다. 만약 type T에 대응하는 타입이 많을 경우 그것 각각에 대한 클래스 코드가 생성될 것입니다. 이는 빌드된 파일의 크기를 크게 만들 수 있으며, 컴파일 타임이 길어지게 만듦니다. 이러한 현상을 Code Explosion이라고 부릅니다.
// [코드 2] 컴파일타임에 생성되는 실행가능한 코드 예시

// 컴파일러는 대략 이렇게 생긴 코드를 생성한다. 이때는 일반화된 타입정보는 
// 남지 않고, 오직 구체적인 타입을 처리하는 코드만이 남는다.
class Calculator.int
{
private:
    int num1;
    int num2;
public:
    Calculator(int num1, int num2) {
        this->num1 = num1;
        this->num2 = num2;
    }
    int GetAdd() {
        return num1 + num2;
    }
};

결국 C++의 template은 하나의 로직을 여러 타입으로 제공하기 위해 각각의 코드를 생성하는 방식으로 구현되었습니다. 그렇다면 Java는 어떨까요?

Java Generics

// [코드 3] Java Generics 예시
class Calculator<T>
{
	private T num1;
	private T num2;
	
	public Calculator(T num1, T num2) {
		this.num1 = num1;
		this.num2 = num2;
	}
	
	public T getAdd() {
		return num1 + num2;
	}
};

class Main {
	public static void main(String[] args) {
		Calculator<Integer> calcuInt = new Calcu<>();
		System.out.println(calcu.getAdd());
		
		Calculator<Long> calcuDouble = new Calcu<>();
		System.out.println(calcu.getAdd());
	}
}

Java에서 Generic Programming은 Generic이라는 개념으로 도입되었습니다. C++ 진영에 STL이 있다면 Java 진영에는 Collection이 있어서, 여러가지 Collection 클래스를 통해 서로 다른 타입에 대한 자료구조를 사용할 수 있습니다.

Type Erasure

Java의 Generic은 다음과 같은 특징을 같습니다.

  1. 타입 소거
    Calcu<Integer> 와 관련된 코드는 컴파일타임을 거치고 난 이후의 바이너리 코드에서는 Integer와 관련된 정보는 모두 삭제되고, 내부적으로는 Object를 다루는 바이너리 코드로 변경됩니다. 모든 객체의 조상인 Object로 데이터를 다루기 때문에 모든 데이터 타입을 하나의 클래스를 통해 다룰 수 있습니다.

  2. 자동 형변환
    하지만 모든 타입을 Object로 바꾸게 된다면 타입을 온전하게 사용할 수 없을 것입니다. 따라서 자바 컴파일러는 제너릭 요소를 꺼내는 코드에 타입 캐스팅하는 코드를 넣습니다. 이를 통해 개발자는 명시적으로 캐스팅을 작성하지 않아도 되며, 타입 안정성을 보장받을 수 있습니다.

Java의 Generic도 c++의 template과 마찬가지로 하나의 로직만 작성하면 여러개의 데이터 타입을 처리할 수 있도록 만들 수 있습니다. 하지만 Java의 generic은 사용되는 타입을 처리할 수 있는 코드를 각각 생성해내는 방식이 아닙니다. Object, 혹은 특정 클래스에 해당하는 코드로 치환하여 동작할 뿐입니다.

따라서 Java에서는 Code Explosion이 발생하지 않습니다. Java에서는 하나의 코드(명령어 묶음)를 재활용합니다. 이는 분명한 장점이지만 단점 역시 존재합니다. c++ 프로그램의 런타임에는 각각의 제너릭 클래스는 그것을 처리하는 타입 정보를 갖고 있어서 이를 통해 리플렉션을 사용하여 특수한 처리를 할 수 있습니다. 하지만 Java의 경우 런타임에 제너릭 타입 정보는 소멸되어 알 수 없다는 단점을 갖고 있습니다.

Compile-time Polymorphism vs Run-time Polymorphism

Polymorphism — providing a single interface to entities of different types. Virtual functions provide dynamic (run-time) polymorphism through an interface provided by a base class. Overloaded functions and templates provide static (compile-time) polymorphism

다형성은 하나의 이름을 가진 메서드로부터 여러 동작이 수행될 수 있는 것을 의미합니다. 다형성은 두 갈래로 나뉩니다.

Dynamic Polymorphism, 혹은 Run-time Polymorphism으로 불리는 것으로 오버라이딩에 의해 하나의 메서드가 실제 참조 객체를 기반으로 하여 서로 다른 동작을 하는 것을 의미합니다. 이 때 동작이 결정되는 시점은 런타임이므로, 이러한 다형성은 런타임 다형성이라고 부릅니다.

한 편, 하나의 이름을 가진 메서드의 동작이 컴파일 타임에 서로 다른 동작으로 결정되는 경우도 있습니다. 오버로딩과 Generic 메서드가 그 예시입니다. 이러한 다형성은 Static Polymorphism, 혹은 Compile-time Polymorphism이라고 부릅니다.

오버로딩은 서로 다른 파라미터를 통해 하나의 이름을 가진 메서드가 서로 다른 명령을 수행하게 만드는 방법입니다. 제너릭은 일반화된 타입으로 다양한 인자를 처리할 수 있게 하는 것입니다. 이들은 모두 하나의 메서드 이름으로 여러가지 동작을 할 수 있게 만들면서, 어떤 동작을 수행할 지에 대해 컴파일 타임에 결정된다는 특징이 있습니다.

'Computer Science > Java' 카테고리의 다른 글

상속이 안티패턴이라고?  (0) 2024.03.17
[Java] Generics, Annotation, Enum  (0) 2022.05.26
Java - 예외처리 정리  (2) 2022.05.16