제너릭 클래스는 Dart에서 다양한 타입을 유연하게 처리할 수 있도록 해주는 중요한 기능이다. 제너릭을 사용하면 클래스나 메소드에서 구체적인 타입을 지정하지 않고, 타입 매개변수를 사용하여 다양한 타입을 처리할 수 있다. 이를 통해 코드의 재사용성을 높이고, 타입 안정성을 보장하면서도 더 범용적인 코드를 작성할 수 있게 된다.

제너릭 클래스의 기본 구조

제너릭 클래스는 타입 매개변수를 받아들이는 클래스이다. 이를 통해 특정 타입에 종속되지 않으면서도, 다양한 데이터 타입을 처리할 수 있는 클래스를 만들 수 있다. Dart에서는 클래스명 뒤에 <T> 형식으로 제너릭 타입을 선언할 수 있다.

class Box<T> {
  T value;

  Box(this.value);

  T getValue() {
    return value;
  }
}

위 코드에서는 Box<T>가 제너릭 클래스를 나타낸다. 여기서 T는 타입 매개변수로, 이 클래스는 T로 전달된 타입을 사용하여 value 필드를 선언하고, 메소드에서 해당 타입을 반환한다.

타입 매개변수

타입 매개변수는 클래스나 메소드에서 선언될 수 있으며, 해당 타입을 이용해 다양한 작업을 수행할 수 있다. Dart에서 제너릭 클래스의 타입 매개변수는 List<int>, List<String> 등과 같이 구체적인 타입으로 대체된다. 이를 통해 코드가 더 유연해진다.

제너릭 클래스의 장점

1. 코드 재사용성

제너릭 클래스를 사용하면 하나의 클래스에서 다양한 데이터 타입을 처리할 수 있다. 예를 들어, 위의 Box 클래스는 Box<int>로 선언하여 정수를 저장할 수도 있고, Box<String>으로 선언하여 문자열을 저장할 수도 있다. 이렇게 코드를 중복 없이 재사용할 수 있다.

2. 타입 안정성

제너릭 클래스는 컴파일 시점에 타입 검사를 할 수 있도록 도와준다. 이는 타입 안정성을 보장하여, 타입 불일치로 인한 런타임 오류를 예방할 수 있다.

제너릭 클래스에서 타입 매개변수 제한

Dart에서는 제너릭 타입에 특정 타입만 허용되도록 제한할 수 있다. 이를 '타입 제한'이라고 부르며, extends 키워드를 사용한다. 예를 들어, 특정 타입이나 그 하위 클래스들만 허용하고 싶을 때 타입 제한을 걸 수 있다.

class NumberBox<T extends num> {
  T value;

  NumberBox(this.value);

  T add(T other) {
    return value + other;
  }
}

위 코드에서는 Tnum 타입을 상속하는 타입만 올 수 있도록 제한하였다. 즉, NumberBox<int>NumberBox<double>은 허용되지만, NumberBox<String>은 컴파일 오류를 발생시킨다.

제너릭 클래스와 타입 안전성

제너릭 클래스를 사용하면 코드에서 타입에 대한 안전성을 확보할 수 있다. 이는 특히 다양한 데이터 타입을 처리해야 하는 클래스에서 유용하다. 예를 들어, 다음과 같이 제너릭 타입을 사용하지 않은 경우와 제너릭을 사용한 경우를 비교해 보자.

제너릭을 사용하지 않은 코드:

class NonGenericBox {
  var value;

  NonGenericBox(this.value);
}

위의 NonGenericBox 클래스는 value 필드의 타입을 var로 지정하였다. 이는 모든 타입을 저장할 수 있게 해주지만, 이후에 해당 값을 사용할 때 어떤 타입인지 알기 어렵다. 이로 인해 런타임에 타입 오류가 발생할 가능성이 높아진다.

제너릭을 사용한 코드:

class GenericBox<T> {
  T value;

  GenericBox(this.value);
}

GenericBox 클래스는 제너릭 타입 매개변수 T를 사용하여, 생성 시점에 타입을 지정할 수 있다. 이렇게 하면, 클래스 내부에서 타입이 고정되기 때문에 이후 코드에서 해당 타입을 추론할 수 있고, 컴파일 시점에 타입 오류를 잡아낼 수 있다.

타입 매개변수의 다중 사용

제너릭 클래스는 단일 타입 매개변수뿐만 아니라 여러 개의 타입 매개변수를 가질 수 있다. 이를 통해 더욱 복잡한 자료 구조를 유연하게 처리할 수 있다.

class Pair<K, V> {
  K key;
  V value;

  Pair(this.key, this.value);

  K getKey() {
    return key;
  }

  V getValue() {
    return value;
  }
}

위 예제에서 Pair<K, V>는 두 개의 타입 매개변수 KV를 받는다. 이를 통해 서로 다른 타입의 데이터를 쌍으로 묶어 관리할 수 있다. Pair<String, int>와 같은 방식으로 사용할 수 있다.

이런 다중 타입 매개변수는 특히 Map이나 Tuple과 같은 자료 구조에서 유용하다. 두 개 이상의 서로 다른 데이터 타입을 연관짓거나, 복수의 값을 함께 처리해야 할 때 활용된다.

제너릭과 null safety

Dart의 null safety 시스템과 제너릭은 상호작용하여 더욱 안전한 코드를 작성할 수 있게 해준다. 제너릭 클래스에 nullable 타입을 사용할 경우, 타입 시스템은 해당 값이 null일 수 있음을 알고 처리할 수 있다.

예를 들어, 제너릭 클래스에서 T?와 같이 nullable 타입을 허용할 수 있다.

class NullableBox<T> {
  T? value;

  NullableBox(this.value);

  bool hasValue() {
    return value != null;
  }
}

위의 NullableBox 클래스는 T? 타입을 사용하여, value가 null일 수 있음을 명시한다. hasValue 메소드에서 null 여부를 체크하여 안전한 처리를 할 수 있다.

제너릭 클래스와 상속

제너릭 클래스는 다른 클래스에서 상속받아 확장할 수 있다. 이때도 제너릭 타입은 그대로 유지되거나, 새롭게 정의할 수 있다. Dart에서는 부모 클래스의 제너릭 타입을 그대로 사용하거나, 자식 클래스에서 타입을 고정할 수도 있다.

class AnimalBox<T extends Animal> {
  T animal;

  AnimalBox(this.animal);
}

class DogBox extends AnimalBox<Dog> {
  DogBox(Dog dog) : super(dog);

  void bark() {
    animal.bark();
  }
}

위 예제에서는 AnimalBox라는 제너릭 클래스를 상속받은 DogBox 클래스가 있다. DogBoxT 타입을 Dog으로 고정하여, Dog 객체만 처리하도록 제한한다.

제너릭 클래스와 제약 조건

Dart에서 제너릭 타입을 사용할 때는 extends 키워드를 사용해 특정 타입으로 제약을 걸 수 있다. 앞서 설명한 것처럼 제약 조건을 설정하면, 제너릭 클래스가 처리할 수 있는 타입을 한정할 수 있고, 특정 메소드나 속성에 접근할 수 있게 된다.

제약 조건을 사용하는 이유

  1. 타입 안정성: 제약 조건을 통해, 제너릭 타입이 특정 클래스나 인터페이스를 반드시 상속받게 하여 해당 클래스의 메소드와 속성을 사용할 수 있다.
  2. 코드 가독성 향상: 제약 조건을 사용하면 타입 매개변수가 특정 타입 이상임을 명시적으로 알 수 있어, 코드의 가독성이 높아진다.

예시: 상속된 메소드 사용

다음은 TComparable을 상속하는 경우이다. 이는 T 타입의 인스턴스가 compareTo 메소드를 사용할 수 있도록 한다.

class SortedBox<T extends Comparable> {
  T value1;
  T value2;

  SortedBox(this.value1, this.value2);

  T getLesserValue() {
    return value1.compareTo(value2) < 0 ? value1 : value2;
  }
}

위 코드에서는 SortedBox<T extends Comparable>T에 대해 Comparable을 상속받도록 제약을 설정하였다. 이로 인해 compareTo 메소드를 사용해 두 값을 비교할 수 있게 되었으며, 더 작은 값을 반환하는 getLesserValue 메소드를 작성할 수 있다.

상속된 제너릭 클래스에서의 타입 제약

제너릭 타입에 제약을 걸 때, 상속된 클래스에서도 제약이 유지되거나 변경될 수 있다. 상속된 클래스에서 제약을 확장하거나 더 구체적으로 설정할 수 있다.

class Vehicle {}

class Car extends Vehicle {}

class Garage<T extends Vehicle> {
  T vehicle;

  Garage(this.vehicle);
}

class CarGarage extends Garage<Car> {
  CarGarage(Car car) : super(car);

  void drive() {
    print("Driving a car");
  }
}

위 코드에서는 Garage 클래스가 T에 대해 Vehicle 타입을 상속받도록 제약을 걸었다. 이때 CarGarage 클래스는 Car 타입을 고정하여, Car만을 처리하도록 설계되었다. 이를 통해 Car에만 해당되는 메소드(drive())를 구현할 수 있다.

제너릭 클래스와 Iterable

Dart의 제너릭 클래스는 Iterable과 같은 컬렉션 타입에서도 강력하게 사용된다. Dart의 List, Set, Map과 같은 컬렉션 클래스들은 모두 제너릭을 기반으로 작성되어 있으며, 각 컬렉션에 저장되는 값의 타입을 제어할 수 있다.

List<int> intList = [1, 2, 3, 4];
List<String> stringList = ["a", "b", "c"];

위 코드에서 List<int>는 정수 타입을 저장하는 리스트이고, List<String>은 문자열 타입을 저장하는 리스트이다. 이처럼 제너릭을 사용하면 컬렉션에서의 타입 안정성을 보장할 수 있다. Dart의 제너릭 컬렉션 클래스는 내부적으로 효율적인 데이터 처리를 가능하게 한다.

제너릭 클래스와 타입 추론

Dart는 제너릭 클래스를 사용할 때, 타입을 명시적으로 지정하지 않더라도 컴파일러가 타입을 추론할 수 있다. 이는 코드의 간결성을 높이고, 불필요한 타입 선언을 줄여준다.

var box = Box(123);

위의 코드에서는 Box<int>로 타입을 명시하지 않았지만, Dart는 자동으로 Box<int>로 추론한다. 컴파일러가 매개변수로 전달된 값의 타입을 기반으로 제너릭 타입을 추론하기 때문에, 이처럼 명시적인 타입 선언 없이도 제너릭 클래스를 사용할 수 있다.

상호 참조 제너릭 타입

상호 참조되는 제너릭 타입을 처리할 때는 서로 다른 타입 매개변수를 사용하여 해결할 수 있다. 이 방식은 복잡한 데이터 구조를 설계할 때 유용하다.

class Node<T> {
  T value;
  Node<T>? next;

  Node(this.value);
}

위 코드에서는 Node<T>가 자신의 타입 매개변수를 재귀적으로 참조한다. 이는 단일 연결 리스트와 같은 자료 구조를 설계할 때 유용하게 사용할 수 있다.

제너릭 클래스와 타입 계층 구조

제너릭 클래스는 Dart의 타입 계층 구조 내에서 매우 유연하게 작동한다. 예를 들어, List<Object>는 모든 타입을 허용하지만, 그보다 구체적인 타입인 List<int>는 오직 정수만을 저장할 수 있다. 이러한 타입 계층 구조를 활용하여 다양한 수준의 추상화를 구현할 수 있다.

List<Object> objectList = [1, "string", 3.0];
List<int> intList = [1, 2, 3];

위의 objectListObject 타입을 받아들이므로 다양한 타입의 객체를 포함할 수 있지만, intList는 오직 정수만을 허용한다. 제너릭을 사용하면 이러한 차이를 명확하게 구현할 수 있다.