레코드
레코드 (Records)
Dart의 레코드(record)는 여러 값을 하나로 묶는 익명·불변·집계 타입이에요. 여러 값을 반환하거나 간단한 데이터 묶음이 필요할 때 유용하죠. 하나씩 살펴볼게요.
출처: Dart 공식 문서
본문
버전 안내
레코드는 언어 버전이 최소 3.0 이상이어야 해요.
레코드는 익명이고 불변인 집계(aggregate) 타입이에요. 다른 컬렉션 타입처럼 여러 객체를 단일 객체로 묶어 주지만, 다른 컬렉션 타입과 달리 크기가 고정돼 있고 이질적(heterogeneous)이며 타입이 지정돼 있어요.
레코드는 실제 값이에요. 변수에 저장하고, 중첩하고, 함수에 전달하고 받을 수 있으며, 리스트·맵·셋 같은 자료구조에 저장할 수도 있어요.
레코드 문법 (Record syntax)
레코드 표현식(record expression) 은 이름이 있는(named) 또는 위치 기반(positional) 필드를 콤마로 구분해 괄호로 감싼 형태예요.
var record = ('first', a: 2, b: true, 'last');
레코드 타입 annotation(record type annotation) 은 타입을 콤마로 구분해 괄호로 감싼 형태예요. 레코드 타입 annotation으로 반환 타입과 파라미터 타입을 정의할 수 있어요. 예를 들어 다음 (int, int)들은 레코드 타입 annotation이에요.
(int, int) swap((int, int) record) {
var (a, b) = record;
return (b, a);
}
레코드 표현식과 타입 annotation의 필드는 함수에서 파라미터와 인자가 동작하는 방식과 거울처럼 대응돼요. 위치 필드는 괄호 안에 그대로 들어가요.
// Record type annotation in a variable declaration:
(String, int) record;
// Initialize it with a record expression:
record = ('A string', 123);
레코드 타입 annotation에서 이름이 있는 필드는 모든 위치 필드 뒤에, 중괄호로 감싼 '타입-이름' 쌍의 구역 안에 들어가요. 레코드 표현식에서는 각 필드 값 앞에 이름을 두고 뒤에 콜론을 붙여요.
// Record type annotation in a variable declaration:
({int a, bool b}) record;
// Initialize it with a record expression:
record = (a: 123, b: true);
레코드 타입에서 이름이 있는 필드의 이름은 레코드의 타입 정의, 즉 그 형태(shape) 의 일부예요. 이름이 다른 이름 필드를 가진 두 레코드는 서로 다른 타입이에요.
({int a, int b}) recordAB = (a: 1, b: 2);
({int x, int y}) recordXY = (x: 3, y: 4);
// Compile error! These records don't have the same type.
// recordAB = recordXY;
레코드 타입 annotation에서는 위치 필드에도 이름을 붙일 수 있어요. 다만 그 이름은 문서화 목적일 뿐 레코드의 타입에는 영향을 주지 않아요.
(int a, int b) recordAB = (1, 2);
(int x, int y) recordXY = (3, 4);
recordAB = recordXY; // OK.
이는 함수 선언이나 함수 typedef의 위치 파라미터에 이름을 붙일 수 있지만 그 이름이 함수의 시그니처에 영향을 주지 않는 것과 비슷해요.
더 많은 내용과 예시는 레코드 타입과 레코드 동등성을 확인해 보세요.
레코드 필드 (Record fields)
레코드 필드는 내장 getter를 통해 접근할 수 있어요. 레코드는 불변이므로 필드에는 setter가 없어요.
이름 필드는 같은 이름의 getter를 노출하고, 위치 필드는 이름 필드를 건너뛴 $<위치> 이름의 getter를 노출해요.
var record = ('first', a: 2, b: true, 'last');
print(record.$1); // Prints 'first'
print(record.a); // Prints 2
print(record.b); // Prints true
print(record.$2); // Prints 'last'
레코드 필드 접근을 더 간결하게 만들고 싶다면 Patterns 페이지를 확인해 보세요.
레코드 타입 (Record types)
개별 레코드 타입에 대한 타입 선언은 없어요. 레코드는 필드 타입에 기반한 구조적 타입(structurally typed) 이에요. 레코드의 형태(필드 집합, 필드 타입, 그리고 있으면 이름)가 레코드의 타입을 유일하게 결정해요.
레코드의 각 필드는 각자의 타입을 가져요. 같은 레코드 안에서도 필드 타입이 서로 다를 수 있어요. 타입 시스템은 레코드에서 접근하는 각 지점에서 각 필드의 타입을 알고 있어요.
(num, Object) pair = (42, 'a');
var first = pair.$1; // Static type `num`, runtime type `int`.
var second = pair.$2; // Static type `Object`, runtime type `String`.
서로 연관 없는 두 라이브러리가 같은 필드 집합의 레코드를 만들었다고 생각해 보세요. 두 라이브러리가 서로 결합돼 있지 않더라도, 타입 시스템은 그 레코드들이 같은 타입임을 이해해요.
레코드 형태에 대한 고유 타입은 선언할 수 없지만, 가독성과 재사용을 위해 타입 별칭(alias)을 만들 수 있어요. 언제, 어떻게 하는지는 레코드와 typedef를 확인해 보세요.
레코드 동등성 (Record equality)
두 레코드가 같은 형태(필드 집합)를 가지면서 대응하는 필드 값이 같다면 서로 같아요. 이름 필드의 순서는 레코드의 형태에 포함되지 않으므로, 이름 필드의 순서는 동등성에 영향을 주지 않아요.
예를 들면 이렇게요.
(int x, int y, int z) point = (1, 2, 3);
(int r, int g, int b) color = (1, 2, 3);
print(point == color); // Prints 'true'.
({int x, int y, int z}) point = (x: 1, y: 2, z: 3);
({int r, int g, int b}) color = (r: 1, g: 2, b: 3);
print(point == color); // Prints 'false'. Lint: Equals on unrelated types.
레코드는 필드 구조에 기반해 hashCode와 == 메서드를 자동으로 정의해요.
여러 값 반환 (Multiple returns)
레코드를 사용하면 함수가 여러 값을 하나로 묶어 반환할 수 있어요. 반환된 레코드에서 값을 꺼내려면 구조 분해(destructure)를 통해 패턴 매칭으로 값을 지역 변수에 풀면 돼요.
// Returns multiple values in a record:
(String name, int age) userInfo(Map<String, dynamic> json) {
return (json['name'] as String, json['age'] as int);
}
final json = <String, dynamic>{'name': 'Dash', 'age': 10, 'color': 'blue'};
// Destructures using a record pattern with positional fields:
var (name, age) = userInfo(json);
/* Equivalent to:
var info = userInfo(json);
var name = info.$1;
var age = info.$2;
*/
이름 필드를 사용해 레코드를 구조 분해할 수도 있어요. 콜론 : 문법을 쓰는데, 자세한 내용은 Pattern types 페이지에서 볼 수 있어요.
({String name, int age}) userInfo(Map<String, dynamic> json)
// ···
// Destructures using a record pattern with named fields:
final (:name, :age) = userInfo(json);
레코드 없이도 함수에서 여러 값을 반환할 수 있지만, 다른 방법에는 단점이 있어요. 예를 들어 클래스를 만드는 건 훨씬 장황하고, List나 Map 같은 다른 컬렉션 타입을 쓰면 타입 안전성을 잃어요.
정보
레코드의 다중 반환과 이질 타입 특성 덕분에 서로 다른 타입의 Future들을 병렬화할 수 있어요. 자세한 내용은 dart:async 문서에서 확인할 수 있어요.
간단한 데이터 구조로서의 레코드 (Records as simple data structures)
레코드는 데이터만 담아요. 그게 필요할 전부라면, 새 클래스를 선언할 필요 없이 바로 사용 가능하고 쓰기 쉬워요. 모두 같은 형태를 가진 데이터 튜플의 간단한 리스트가 필요하다면, 레코드의 리스트(list of records) 가 가장 직접적인 표현이에요.
예를 들어 이 '버튼 정의(button definitions)' 리스트를 볼게요.
final buttons = [
(
label: "Button I",
icon: const Icon(Icons.upload_file),
onPressed: () => print("Action -> Button I"),
),
(
label: "Button II",
icon: const Icon(Icons.info),
onPressed: () => print("Action -> Button II"),
)
];
이 코드는 추가 선언 없이 바로 작성할 수 있어요.
레코드와 typedef (Records and typedefs)
typedefs를 사용해 레코드 타입 자체에 이름을 붙이고, 전체 레코드 타입을 쓰는 대신 그 이름을 쓸 수 있어요. 이 방법으로 목록의 현재 항목에 null 값이 없더라도 일부 필드가 null일 수 있음(?)을 명시할 수 있어요.
typedef ButtonItem = ({String label, Icon icon, void Function()? onPressed});
final List<ButtonItem> buttons = [
// ...
];
레코드 타입은 구조적 타입이므로 ButtonItem 같은 이름을 붙이는 것은 단지 그 구조적 타입 ({String label, Icon icon, void Function()? onPressed})을 가리키기 쉽게 하는 별칭을 도입할 뿐이에요.
모든 코드가 레코드 타입을 별칭으로 참조하게 하면, 나중에 모든 참조를 수정할 필요 없이 레코드의 구현을 바꾸기가 더 쉬워져요.
코드는 주어진 버튼 정의를 단순한 클래스 인스턴스와 똑같이 다룰 수 있어요.
List<Container> widget = [
for (var button in buttons)
Container(
margin: const EdgeInsets.all(4.0),
child: OutlinedButton.icon(
onPressed: button.onPressed,
icon: button.icon,
label: Text(button.label),
),
),
];
나중에 레코드 타입을 클래스 타입으로 바꿔 메서드를 추가하기로 결정할 수도 있어요.
class ButtonItem {
final String label;
final Icon icon;
final void Function()? onPressed;
ButtonItem({required this.label, required this.icon, this.onPressed});
bool get hasOnPressed => onPressed != null;
}
또는 확장 타입(extension type)으로요.
extension type ButtonItem._(({String label, Icon icon, void Function()? onPressed}) _) {
String get label => _.label;
Icon get icon => _.icon;
void Function()? get onPressed => _.onPressed;
ButtonItem({required String label, required Icon icon, void Function()? onPressed})
: this._((label: label, icon: icon, onPressed: onPressed));
bool get hasOnPressed => _.onPressed != null;
}
그리고 나서 그 타입의 생성자로 버튼 정의 리스트를 만들면 돼요.
final List<ButtonItem> buttons = [
ButtonItem(
label: "Button I",
icon: const Icon(Icons.upload_file),
onPressed: () => print("Action -> Button I"),
),
ButtonItem(
label: "Button II",
icon: const Icon(Icons.info),
onPressed: () => print("Action -> Button II"),
)
];
이 역시 그 리스트를 사용하는 코드를 바꿀 필요 없이 가능해요.
어떤 타입을 바꾸든 그것을 사용하는 코드는 가정을 세우지 않도록 매우 조심해야 해요. 타입 별칭은 참조로 사용하는 코드에 대해, 별칭이 가리키는 값이 레코드라는 어떤 보호나 보장도 제공하지 않아요. 확장 타입도 보호를 거의 제공하지 못해요. 완전한 추상화와 캡슐화를 제공할 수 있는 것은 오직 클래스뿐이에요.
더 알아보기
- Patterns — 구조 분해와 패턴 매칭
- typedefs — 타입 별칭
- Collections — 컬렉션 타입과의 차이