Dart에서 컬렉션 탐구하기

Dart에서 컬렉션 탐구하기

리스트나 맵을 만들려고 add(), addAll(), map(), toList()를 써본 적이 있다면, collection if, collection for, spread를 꼭 확인해보세요. 지난해 Dart는 버전 2.3에서 이 기능들을 추가했어요.

이 글에서는 컬렉션을 살펴보고, 이 새 기능들을 탐구하며, 재미있는 예시 몇 가지를 볼게요. 이 기능들을 마스터하면 코드를 더 간결하게, 그리고 읽기 쉽게 만들 수 있어요.

이 글은 John Ryan님이 2020년 9월 15일에 작성한 글로, 읽는 데 약 8분 정도 걸려요.

출처: Exploring collections in Dart

본문

컬렉션(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)**으로 구분하기 때문이에요. 타입 시스템은 ab 파라미터의 타입에 기반해 타입을 결정해요. 보통 내용에 기반해 결정할 수 있는데, 예를 들어 {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가 같은 요소의 일부이기 때문이에요. ifelse를 쉼표 앞에 함께 두는 것이 그걸 컬렉션의 다음 요소와 분리되게 해줘요.

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 ifcollection for의 본문에는 어떤 표현식이든 들어갈 수 있어요.
  • collection ifcollection 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의 초기 언어 제안을 확인해보세요.

더 알아보기