제너릭 클래스는 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;
}
}
위 코드에서는 T
가 num
타입을 상속하는 타입만 올 수 있도록 제한하였다. 즉, 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>
는 두 개의 타입 매개변수 K
와 V
를 받는다. 이를 통해 서로 다른 타입의 데이터를 쌍으로 묶어 관리할 수 있다. 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
클래스가 있다. DogBox
는 T
타입을 Dog
으로 고정하여, Dog
객체만 처리하도록 제한한다.
제너릭 클래스와 제약 조건
Dart에서 제너릭 타입을 사용할 때는 extends
키워드를 사용해 특정 타입으로 제약을 걸 수 있다. 앞서 설명한 것처럼 제약 조건을 설정하면, 제너릭 클래스가 처리할 수 있는 타입을 한정할 수 있고, 특정 메소드나 속성에 접근할 수 있게 된다.
제약 조건을 사용하는 이유
- 타입 안정성: 제약 조건을 통해, 제너릭 타입이 특정 클래스나 인터페이스를 반드시 상속받게 하여 해당 클래스의 메소드와 속성을 사용할 수 있다.
- 코드 가독성 향상: 제약 조건을 사용하면 타입 매개변수가 특정 타입 이상임을 명시적으로 알 수 있어, 코드의 가독성이 높아진다.
예시: 상속된 메소드 사용
다음은 T
가 Comparable
을 상속하는 경우이다. 이는 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];
위의 objectList
는 Object
타입을 받아들이므로 다양한 타입의 객체를 포함할 수 있지만, intList
는 오직 정수만을 허용한다. 제너릭을 사용하면 이러한 차이를 명확하게 구현할 수 있다.