PHpullh
언어 비교/제네릭

LANGUAGE COMPARISON

제네릭를 언어별로 비교하기

타입을 매개변수로 받는 코드가 실행 시점에 무엇을 기억하는지 언어별로 비교합니다.

같은 문법, 다른 결과물

List<String> 같은 표기는 여러 언어에서 비슷해 보이지만, 컴파일 후에 남는 것이 전혀 다릅니다. 크게 두 갈래입니다.

Java와 Kotlin은 타입 소거를 택했습니다. 컴파일이 끝나면 타입 인자가 지워져서 실행 중에는 List<String>과 List<Integer>가 같은 클래스입니다. 기존 코드와의 호환을 지키려는 선택이었고, 대가로 실행 시점에 타입을 물어볼 수 없습니다.

C#, Rust, C++은 반대로 타입마다 실제 코드를 만들어 냅니다. C#은 런타임이 타입 정보를 유지하고, Rust와 C++은 컴파일 시점에 타입별 사본을 찍어 냅니다. 실행 속도와 타입 정보를 얻는 대신 컴파일 시간과 바이너리 크기가 늘어납니다.

TypeScript는 또 다른 극단입니다. 제네릭이 전부 컴파일 시점에만 존재하고 결과물인 JavaScript에는 흔적이 남지 않습니다. Go는 오랫동안 제네릭 없이 지내다 뒤늦게 도입했고, PHP는 지금도 문법 차원의 제네릭이 없습니다.

Kotlin

제네릭 클래스 (Generic Classes)

타입 파라미터를 사용하여 다양한 타입을 처리하는 범용 클래스를 설계합니다. 타입 제약으로 안전성을 확보합니다.

// 타입 제약이 있는 제네릭 클래스
class SortedList<T : Comparable<T>> {
    private val items = mutableListOf<T>()

    fun add(item: T) {
        items.add(item)
        items.sort()
    }

    fun getAll(): List<T> = items.toList()
    fun min(): T? = items.firstOrNull()
    fun max(): T? = items.lastOrNull()
}

// 여러 타입 파라미터
class Result<out S, out E>(
    val value: S? = null,
    val error: E? = null
) {
    val isSuccess get() = value != null
    fun <R> map(transform: (S) -> R): Result<R, E> =
        if (isSuccess) Result(value = transform(value!!))
        else Result(error = error)
}

fun main() {
    val list = SortedList<Int>()
    listOf(5, 2, 8, 1).forEach { list.add(it) }
    println(list.getAll())  // [1, 2, 5, 8]

    val ok: Result<String, Exception> = Result(value = "성공")
    val mapped = ok.map { it.uppercase() }
    println(mapped.value)  // 성공
}

Python

TypeVar 제네릭

TypeVar로 제네릭 함수와 클래스를 만들어 타입 안전한 코드를 작성합니다.

from typing import TypeVar, Generic

T = TypeVar('T')
N = TypeVar('N', int, float)  # 제한된 TypeVar

# 제네릭 함수
def first(items: list[T]) -> T:
    return items[0]

print(first([1, 2, 3]))       # 1 (int)
print(first(["a", "b"]))      # "a" (str)

# 제한된 TypeVar
def add_nums(a: N, b: N) -> N:
    return a + b

print(add_nums(1, 2))       # 3
print(add_nums(1.5, 2.3))   # 3.8

# 제네릭 클래스 (Python 3.12+는 class Box[T]: 가능)
class Box(Generic[T]):
    def __init__(self, item: T) -> None:
        self.item = item
    def get(self) -> T:
        return self.item

box = Box(42)
print(box.get())  # 42

Go

제네릭 (Go 1.18+)

Go 1.18에 추가된 제네릭으로 타입 안전한 재사용 가능한 코드를 작성합니다.

package main

import (
	"fmt"
	"golang.org/x/exp/constraints"
)

// 타입 파라미터 — [T constraint]
func Map[T, U any](s []T, fn func(T) U) []U {
	result := make([]U, len(s))
	for i, v := range s { result[i] = fn(v) }
	return result
}

func Filter[T any](s []T, fn func(T) bool) []T {
	var result []T
	for _, v := range s {
		if fn(v) { result = append(result, v) }
	}
	return result
}

// constraints.Ordered — 정렬 가능한 타입 제약
func Min[T constraints.Ordered](a, b T) T {
	if a < b { return a }
	return b
}

// 제네릭 구조체
type Stack[T any] struct {
	data []T
}

func (s *Stack[T]) Push(v T)     { s.data = append(s.data, v) }
func (s *Stack[T]) Pop() (T, bool) {
	if len(s.data) == 0 {
		var zero T
		return zero, false
	}
	n := len(s.data) - 1
	v := s.data[n]
	s.data = s.data[:n]
	return v, true
}
func (s *Stack[T]) Len() int { return len(s.data) }

// 커스텀 타입 제약
type Number interface {
	~int | ~int32 | ~int64 | ~float32 | ~float64
}

func Sum[T Number](nums []T) T {
	var total T
	for _, n := range nums { total += n }
	return total
}

func main() {
	nums := []int{1, 2, 3, 4, 5}
	doubled := Map(nums, func(n int) int { return n * 2 })
	fmt.Println(doubled) // [2 4 6 8 10]

	strs := Map(nums, func(n int) string {
		return fmt.Sprintf("item%d", n)
	})
	fmt.Println(strs)

	evens := Filter(nums, func(n int) bool { return n%2 == 0 })
	fmt.Println(evens) // [2 4]

	fmt.Println(Min(3, 5))       // 3
	fmt.Println(Min("a", "b"))   // a

	var s Stack[string]
	s.Push("hello"); s.Push("world")
	if v, ok := s.Pop(); ok { fmt.Println(v) } // world

	fmt.Println(Sum([]float64{1.1, 2.2, 3.3}))
}

Java

제네릭 메서드

타입 파라미터를 메서드 레벨에서 선언하여 재사용 가능한 유틸리티를 만듭니다.

import java.util.*;

public class GenericMethods {
    // 제네릭 메서드 — 반환 타입 앞에 <T> 선언
    static <T> List<T> listOf(T... items) {
        return new ArrayList<>(Arrays.asList(items));
    }

    // Bounded 타입 파라미터
    static <T extends Comparable<T>> T max(T a, T b) {
        return a.compareTo(b) >= 0 ? a : b;
    }

    // 여러 타입 파라미터
    static <K, V> Map<K, V> mapOf(K key, V value) {
        Map<K, V> map = new HashMap<>();
        map.put(key, value);
        return map;
    }

    public static void main(String[] args) {
        List<String> names = listOf("Alice", "Bob");
        int bigger = max(10, 20);             // 타입 추론
        String later = max("apple", "banana");
        Map<String, Integer> m = mapOf("age", 30);
        System.out.printf("%s, %d, %s, %s%n", names, bigger, later, m);
    }
}

PHP

제네릭 문법이 없습니다

PHP에는 제네릭 타입 파라미터 문법이 없습니다. 대신 array에 무엇이 들어 있는지를 PHPDoc 주석(@param list<User>)으로 적고 정적 분석 도구가 검사하도록 맡기거나, 컬렉션마다 전용 클래스를 만들어 타입을 좁힙니다. 런타임에는 검사되지 않으므로 경계에서 값의 형태를 직접 확인해야 합니다.

JavaScript

제네릭 함수

제네릭이 아니라 number로 고정된 덧셈 함수이며, add(17, 2)의 결과를 출력합니다. 다루는 타입이 하나뿐이라면 굳이 타입 매개변수를 도입할 이유가 없습니다. 이 함수를 <T>로 일반화하면 + 연산이 임의의 T에는 허용되지 않아 제약을 따로 걸어야 합니다.

// 제네릭 함수
function add(a, b) {
  return a + b;
}
console.log(add(17, 2));

TypeScript

제네릭 함수

제네릭이 아니라 number로 고정된 덧셈 함수이며, add(17, 2)의 결과를 출력합니다. 다루는 타입이 하나뿐이라면 굳이 타입 매개변수를 도입할 이유가 없습니다. 이 함수를 <T>로 일반화하면 + 연산이 임의의 T에는 허용되지 않아 제약을 따로 걸어야 합니다.

// 제네릭 함수
function add(a: number, b: number): number {
  return a + b;
}
console.log(add(17, 2));

C#

제네릭 함수

제네릭이 아니라 number로 고정된 덧셈 함수이며, add(17, 2)의 결과를 출력합니다. 다루는 타입이 하나뿐이라면 굳이 타입 매개변수를 도입할 이유가 없습니다. 이 함수를 <T>로 일반화하면 + 연산이 임의의 T에는 허용되지 않아 제약을 따로 걸어야 합니다.

// 제네릭 함수
using System;

int Add(int a, int b) => a + b;
Console.WriteLine(Add(17, 2));

C++

제네릭 함수

제네릭이 아니라 number로 고정된 덧셈 함수이며, add(17, 2)의 결과를 출력합니다. 다루는 타입이 하나뿐이라면 굳이 타입 매개변수를 도입할 이유가 없습니다. 이 함수를 <T>로 일반화하면 + 연산이 임의의 T에는 허용되지 않아 제약을 따로 걸어야 합니다.

// 제네릭 함수
#include <iostream>

int add(int a, int b) { return a + b; }
int main() {
    std::cout << add(17, 2) << "\n";
}

Rust

제네릭 함수

제네릭이 아니라 number로 고정된 덧셈 함수이며, add(17, 2)의 결과를 출력합니다. 다루는 타입이 하나뿐이라면 굳이 타입 매개변수를 도입할 이유가 없습니다. 이 함수를 <T>로 일반화하면 + 연산이 임의의 T에는 허용되지 않아 제약을 따로 걸어야 합니다.

// 제네릭 함수
fn add(a: i32, b: i32) -> i32 { a + b }
fn main() {
    println!("{}", add(17, 2));
}

무엇을 할 수 있는지 어떻게 알리는가

타입을 매개변수로 받으면 그 타입에 대해 아는 것이 없어집니다. 두 값을 비교하거나 더하려면 "이 타입은 비교할 수 있다"는 사실을 알려야 하는데, 이 방식이 언어마다 다릅니다. Java·Kotlin은 상한 타입(T extends Comparable), Rust는 트레이트 경계(T: Ord), Go는 인터페이스 제약, C#은 where 절을 씁니다.

C++ 템플릿은 오랫동안 제약을 적지 않고 일단 대입해 보고 안 되면 에러를 내는 방식이었습니다. 그래서 템플릿 오류 메시지가 악명 높게 길어졌고, 이후 concepts가 도입되어 요구 조건을 미리 적을 수 있게 됐습니다.

소거가 만드는 실제 제약

Java에서 new T[]를 못 쓰고, if (x instanceof List<String>)를 쓸 수 없는 이유가 소거입니다. 오버로드도 걸립니다. f(List<String>)과 f(List<Integer>)는 소거 후 시그니처가 같아져 컴파일되지 않습니다.

실행 시점에 타입이 필요하면 Class<T> 객체를 인자로 함께 받는 우회가 흔히 쓰입니다. 라이브러리에서 이 패턴을 보게 되는 이유가 대부분 여기 있습니다.

공변성 — 컨테이너를 대입할 수 있는가

Dog가 Animal의 하위 타입일 때 List<Dog>도 List<Animal>의 하위 타입일까요. 대부분의 언어에서 답은 아니오입니다. 허용하면 List<Animal>로 받은 자리에 고양이를 넣을 수 있게 되어 원래 리스트가 깨지기 때문입니다.

Java는 호출 지점에서 ? extends·? super로 방향을 정하고, Kotlin과 C#은 타입을 선언할 때 out·in으로 미리 표시합니다. 읽기만 할 것인지 쓰기도 할 것인지를 타입에 적는 셈인데, 처음에는 번거롭지만 잘못된 대입을 컴파일 단계에서 막아 줍니다.