Dart에서 컬렉션 탐구하기
Dart에서 컬렉션 탐구하기
리스트나 맵을 만들려고 add(), addAll(), map(), toList()를 써본 적이 있다면, collection if, collection for, spread를 꼭 확인해보세요. 지난해 Dart는 버전 2.3에서 이 기능들을 추가했어요.
이 글에서는 컬렉션을 살펴보고, 이 새 기능들을 탐구하며, 재미있는 예시 몇 가지를 볼게요. 이 기능들을 마스터하면 코드를 더 간결하게, 그리고 읽기 쉽게 만들 수 있어요.
이 글은 John Ryan님이 2020년 9월 15일에 작성한 글로, 읽는 데 약 8분 정도 걸려요.
본문
컬렉션(Collections)
먼저 컬렉션이 무엇인지 이해해야 해요. 컬렉션은 다른 객체를 담고 있는 객체예요. 예를 들어:
- List: 길이가 있는 객체들의 정렬된 컬렉션 (또한 *배열(array)*이라고도 불러요)
- Set: 고유한 객체들의 비정렬된 컬렉션
- Map: 키-값 쌍의 비정렬된 컬렉션
- Queue: 양쪽 끝에서 객체를 추가/제거할 수 있는 정렬된 컬렉션
- SplayTreeMap: 자가 균형 이진 트리(self-balancing binary tree)에 기반한 정렬된 키-값 쌍 컬렉션
이 타입들은 dart:collection 패키지에서 사용할 수 있어요. 더 많은 컬렉션 타입을 원한다면 pub.dev의 package:collection을 확인해보세요.
이 컬렉션 타입들은 각각 Iterable을 구현하는데, 이는 컬렉션의 각 객체에 함수를 실행하거나, 첫 번째 객체를 얻거나, 컬렉션의 길이를 정하는 것 같은 공통 동작을 제공해요.
컬렉션 리터럴(Collection literals)
Dart는 세 가지 타입의 컬렉션을 만드는 문법을 지원해요: 리스트 리터럴([]), 맵 리터럴({}), 그리고 셋 리터럴(역시 {})이에요.
리스트 리터럴이에요:
List<String> getArtists() {
return [
'Picasso',
'Warhol',
'Monet',
];
}
맵 리터럴이에요:
Map<String, String> getArtistsByPainting {
return {
'The Old Guitarist': 'Picasso',
'Orange Prince': 'Warhol',
'The Water Lily Pond': 'Monet',
};
}
그리고 Dart 2.3에서 추가된 셋 리터럴이에요:
Set<String> getArtistsSet() {
return {
'Picasso',
'Warhol',
'Monet',
};
}
맵과 셋이 왜 같은 {} 문법을 쓸 수 있는지 궁금할 거예요. 그건 Dart가 **타입 추론(type inference)**으로 구분하기 때문이에요. 타입 시스템은 a와 b 파라미터의 타입에 기반해 타입을 결정해요. 보통 내용에 기반해 결정할 수 있는데, 예를 들어 {1}은 분명 Set이고, {1: 2}는 분명 Map이에요.
참고: {}를 쓰면 기본적으로 맵이 생성돼요. 셋을 만들려면 제네릭 타입 애노테이션 <String>{}을 쓰면 돼요. 제네릭 타입 파라미터 두 개를 쓰면 맵이 돼요: <String, String>{}.
요소의 타입(Types of elements)
컬렉션 리터럴 안의 각 항목은 보통 값이나 표현식이지만, 이 새 기능들 중 하나일 수도 있어요: collection if, collection for, spread. 이 모두를 **요소(element)**라고 불러요.
각 요소는 0개 이상의 항목을 풀어서(unpack) 주변 컬렉션에 넣어요. 예를 들어 문자열 리터럴(가령 "oatmeal")은 항목 하나를 만들지만, collection for는 0개 이상의 항목을 풀어요. 이 기능들은 아래에서 살펴보듯 흥미로운 방식으로 결합할 수도 있어요.
Spreads
spread는 컬렉션(예를 들어 리스트)을 가져와서 그 내용을 주변 컬렉션에 넣어요:
List<String> combineLists(List<String> a, List<String> b) {
return [
...a,
...b,
];
}
위 코드는 이 코드와 동일해요:
List<String> combineLists(List<String> a, List<String> b) {
var list = [];
list.addAll(a);
list.addAll(b);
return list;
}
spread는 맵과 셋 리터럴에서도 쓸 수 있어요:
Map<String, String> combineMaps(Map<String, String> a, Map<String, String> b) {
return {
...a,
...b,
};
}
Set<String> combineSets(Set<String> a, Set<String> b) {
return {
...a,
...b,
};
}
맵과 셋 둘 다에서, 충돌이 있을 때 b의 내용이 a의 내용을 덮어써요. 예를 들어 combineMaps({'foo': 'bar'}, {'foo': 'baz'})를 호출하면 {'foo': 'baz'}를 담은 맵이 돼요.
Null-aware spreads (...?)
null-aware spread는 연산자 뒤의 표현식이 null이 아닐 때만 컬렉션에 내용을 추가해요:
List<String> combineIfExists(List<String> a, List<String> b) {
return [
...?a,
...?b,
];
}
void main() {
var result = combineIfExists(['foo'], null);
print(result); // [foo]
}
Collection if
if, else, else if 키워드를 써서 조건에 따라 컬렉션에 무언가를 추가할 수 있어요. collection if를 쓰는 예시를 볼게요:
class Article {
String title;
DateTime date;
Article(this.title, this.date);
String toString() {
return [
if (title != null) title,
date.toString(),
].join(', ');
}
}
else 키워드는 끝에 추가할 수 있어요:
String toString() {
return [
if (title != null) title else '(no title)',
].join(', ');
}
쉼표가 어디에 있는지 주목하세요. 쉼표는 title 뒤에 올 수 없어요. else가 같은 요소의 일부이기 때문이에요. if와 else를 쉼표 앞에 함께 두는 것이 그걸 컬렉션의 다음 요소와 분리되게 해줘요.
else if를 추가하는 것도 동작해요:
String toString() {
return [
if (title != null) title else '(no title)',
if (date == null)
'(no date)'
else if (date.year == DateTime.now().year)
'this year'
else
'${date.year}',
].join(', ');
}
Collection for
마지막으로, for 키워드를 써서 **수열(sequence)**을 컬렉션에 삽입할 수 있어요:
class Article {
String title;
DateTime date;
List<String> tags;
Article(this.title, this.date, this.tags);
String toString() {
return [
title,
date.toString(),
for (var tag in tags) 'tag: $tag'
].join(', ');
}
}
이 예시에서 for 표현식은 tags 리스트의 각 항목에 대해 문자열을 하나씩 추가해요. Dart의 일반적인 for 루프처럼, tags 표현식은 어떤 Iterable이든 될 수 있어요.
Flutter 코드에서의 컬렉션
Dart를 쓴다면 Flutter 앱을 만드는 데 쓰고 있을 가능성이 높아요. 여기에 설명된 기능들이 Flutter를 염두에 두고 설계됐으니, 몇 가지 Flutter 코드를 살펴볼게요.
build() 메서드 리팩터링
Flutter에서는 build() 메서드에서 위젯 리스트를 만들어 올리는 것이 흔해요:
@override
Widget build(BuildContext context) {
var articleWidgets = articles
.map<Widget>((article) => ArticleWidget(article: article))
.toList();
return ListView(
children: articleWidgets,
);
}
이건 spread로 다시 쓸 수 있어요:
Widget build(BuildContext context) {
return ListView(
children: [
...articles.map((article) => ArticleWidget(article:article))
],
);
}
또는 collection for로:
Widget build(BuildContext context) {
return ListView(
children: [
for (var article in articles)
ArticleWidget(article: article)
],
);
}
첫 번째 코드 조각은 map()을 써서 Article 클래스를 ArticleWidget 객체 컬렉션으로 변환한 다음, spread 연산자를 적용해 주변 리스트로 펼쳐요. 두 번째 예시에서는 collection for 연산자가 이걸 조금 더 간결하게 표현하게 해줘요.
더 큰 build() 메서드
더 복잡한 예시를 보여드릴게요:
Widget build(BuildContext context) {
var headerStyle = Theme.of(context).textTheme.headline4;
return Column(
children: [
if (article.title != null)
Text(article.title, style: headerStyle),
if (article.date != null)
Text(article.date.toString()),
Text('Tags:'),
for (var tag in article.tags)
Text(tag),
],
);
}
Column에 위젯을 배치하는 로직이 읽는 사람이 기대하는 바로 그 자리에 있으니, 코드를 많이 아껴줘요. 이 기능들 이전에는 같은 동작을 얻는 가장 흔한 방법이 변수를 만들고 add()를 호출하는 일반적인 if 문을 쓰는 것이었어요.
이 기능들 결합하기
이 기능들은 흥미로운 방식으로 결합될 수 있어요. 이 섹션의 예시들이 그걸 보여줘요. 몇 가지 유의할 점이 있어요:
- 문법적으로 collection if, collection for, spread는 단일 요소(single element) — 여러 객체를 만든다고 해도요.
- collection if나 collection for의 본문에는 어떤 표현식이든 들어갈 수 있어요.
- collection if나 collection for의 본문에는 어떤 요소든 들어갈 수 있어요.
if와 for 함께 쓰기
collection for 안에 collection if를 써서 리스트를 만드는 코드를 볼게요. 여기서 각 article은 특정 날짜 이후의 것이면 리스트에 추가돼요:
List<Article> recentArticles(List<Article> allArticles) {
var ninetyDaysAgo = DateTime.now().subtract(Duration(days: 90));
return [
for (var article in allArticles)
if (article.date.isAfter(ninetyDaysAgo))
article
];
}
spread를 선호한다면, 반환값을 대신 ...allArticles.where((article) => article.date.isAfter(ninetyDaysAgo))라고 쓸 수도 있어요.
collection if와 spreads 함께 쓰기
collection if는 단일 요소를 받지만, 하나 이상을 포함하고 싶다면 spread를 쓸 수 있어요:
Widget build(BuildContext context) {
return Column(
children: [
if (article.date != null) ...[
Icon(Icons.calendar_today),
Text('${article.date}'),
],
],
);
}
collection 기능과 async-await 함께 쓰기
비동기 호출과 컬렉션 리터럴을 결합할 수도 있어요. 예를 들어, 비동기 호출 그룹을 촉발하는 데 Future.wait()를 쓰는 게 흔한 패턴이에요:
Future<Article> fetchArticle(String id);
Future<List<Article>> fetchArticles() async {
return Future.wait([
fetchArticle('1'),
fetchArticle('2'),
fetchArticle('3'),
]);
}
그 코드는 collection for로 개선할 수 있어요:
Future<List<Article>> fetchArticles(List<String> ids) async {
return Future.wait([
for (var id in ids)
fetchArticle(id),
]);
}
컬렉션 리터럴에 await를 넣는 것도 가능해요. 다만 각 future를 순서대로 기다릴 거예요:
Future<List<Article>> fetchArticles(List<String> ids) async {
return [
// fetches one at a time
for (var id in ids)
await fetchArticle(id),
];
}
위 코드는 이 코드와 동일하기 때문에 순서대로 기다려요:
Future<List<Article>> fetchArticles() async {
return <Article>[
// fetches one at a time
await fetchArticle('1'),
await fetchArticle('2'),
await fetchArticle('3'),
];
}
await for를 써서 Stream을 펼칠 수도 있어요:
Stream<String> get idStream => Stream.fromIterable(['1','2','3']);
Future<List<String>> gatherIds(Stream<String> ids) async {
return [
await for (var id in ids)
id
];
}
void main() async {
print(await gatherIds(idStream)); // [1, 2, 3]
}
이것도 collection if, collection for, spread가 언어의 다른 부분과 함께 어떻게 동작하는지 보여주는 또 다른 예시예요. await for 문을 써봤다면 동작을 짐작할 수 있을 거예요. 스트림에서 새 값을 듣고 본문을 주변 리스트에 넣어요.
더 탐구하기
이 팁들이 더 깔끔한 Dart 코드를 쓰는 데 도움이 되길 바래요. 여기에 언급된 것보다도 이 기능들을 쓸 수 있는 방법은 훨씬 더 많아요. 좋은 기법을 발견하면 [커뮤니티에 공유]하거나 Twitter에서 @dart_lang을 언급해주세요. 더 자세한 내용은 Making Dart a Better Language for UI나 GitHub의 초기 언어 제안을 확인해보세요.