UI를 위한 더 나은 언어로 Dart 만들기
UI를 위한 더 나은 언어로 Dart 만들기
Dart 팀에서는 제가 정말 기대하고 있는 언어 변경 몇 가지를 구현하느라 바쁘게 작업하고 있어요. 모두 컬렉션 리터럴(collection literal), 즉 리스트(Map)·맵·셋을 만드는 내장 문법과 관련이 있어요.
var someList = [1, 2, 3, 4];
var someMap = {key: 1, another: 2};
var someSet = {1, 2, 3, 4};
지금 Dart 코드를 작성하고 있지 않다면, 이것이 여러분과 여러분의 인생 목표에 아주 직접적인 관련이 없을 수도 있어요. 하지만 그래도 계속 읽어 주시길 바래요. 이 기능들은 그 자체로도 흥미롭다고 생각하고, 그 밑바탕에 깔린 실행 모델은 여러분의 뇌를 유용하고/거나 흥미로운 방식으로 늘려 줄 수도 있거든요. 저는 지금 사용하지 않는 언어라도 새 언어 기능에 대해 배우는 게 항상 재미있어요.
본문
Flutter 사용자들이 UI를 구축하는 방법
지난 1년 동안 Dart에 대해 뭔가 들었다면, 아마 Flutter와 관련된 맥락이었을 거예요. 그 이름이 잘 기억나지 않는다면, Flutter는 멀티플랫폼 모바일 앱을 구축하기 위한 UI 프레임워크예요. 여기서 다 설명하긴 어렵지만, 링크를 클릭하면 마음속의 모든 질문에 답해 줄 거예요. (음, 적어도 Flutter에 관한 모든 질문에는요. 고등학교 때 좋아했던 사람이 왜 연락을 안 했는지는 알려주지 않아요.)
어떤 UI 프레임워크든 내리는 핵심 결정 중 하나는 버튼, 색상, 텍스트, 레이아웃 같은 기본 시각적 UI 요소를 어떻게 정의하느냐예요. 그것들을 "템플릿"이나 "마크업" 같은 별도의 형식으로 작성하나요, 아니면 UI의 동작이 정의되는 실행 코드 안에 바로 넣나요? 업계는 15년 정도마다 어느 쪽이 올바른 답인지 뒤집혀요.
Angular와 대부분의 웹 프레임워크는 HTML의 발자취를 따라 템플릿을 사용해요. React는 UI를 JavaScript 안에 넣지만, HTML처럼 보이게 하는 JSX라는 임베디드 DSL도 추가해요. 케이크도 먹고 싶고 그걸 보존하고 싶은 셈이죠. 물론 모든 사람이 HTML을 특히 디저트 같다고 표현하진 않겠지만요.
Flutter는 UI를 일반적인 Dart 표현식 문법을 사용해서 Dart 코드에 바로 넣어요. 보세요:
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Welcome to Flutter',
home: Scaffold(
appBar: AppBar(title: Text('Welcome to Flutter')),
body: Column(
children: [
Text('Hello World'),
Text('This is Flutter'),
Text('Written in Dart')
]
)
)
);
}
}
그 return 키워드 뒤의 모든 것은 한 덩어리의 사용자 인터페이스를 만들어내는 하나의 커다란 중첩 표현식이에요. 이렇게 Dart를 사용하면 몇 가지 실질적인 이점이 있어요.
- 배워야 할 언어가 하나뿐이다: Dart. Dart는 다른 언어에서 온 사람들에게 익숙하도록 설계됐으니, 그렇게 어렵지 않길 바라요.
- UI를 구축할 때 범용 프로그래밍 언어의 모든 추상화 기능을 사용할 수 있어요. 조각들을 재사용 가능한 함수로 끌어올리고, 그 함수에 매개변수를 줘서 생성되는 UI를 다양화하고, 로컬 변수에 저장하고. 여러분 마음대로 하세요.
- 표현력의 벽에 부딪혀 다른 언어로 이식해야 할 일이 없어요. 선언형 언어를 사용해 본 적이 있다면, 그 언어가 실제로 표현할 수 있는 한계에 부딪힌 상황을 겪어봤을 거예요. 그 시점에서 여러분은 하려던 것을 포기하거나, 전체를 더 낮은 수준(보통 명령형)의 언어로 힘들게 다시 쓰게 돼요. Dart라는 완전한 기능을 갖춘 언어 안에 이미 있기 때문에 그 벽에 부딪힐 일이 없고, UI 코드가 자연스럽게 정교해져 가요.
물론 주요 과제이자 사람들이 처음에 선언형 언어를 만드는 이유는, 명령형 언어로 뭔가를 정의하는 게 정말 지루하고 읽기 어려울 수 있다는 거예요.
이 작은 HTML 조각 대신에:
<p>I am an <strong>exciting</strong> paragraph!</p>
이런 걸 작성해야 한다고 상상해 보세요:
doc.beginTag("p");
doc.appendText("I am an ");
doc.beginTag("strong");
doc.appendText("exciting");
doc.endTag("strong");
doc.appendText("paragraph!");
doc.endTag("p");
다행히도 현대 언어와 API는 그렇게 저수준이 아니에요. 문(statement)은 명령형이지만 표현식(expression)은 꽤 선언적이에요. 위의 코드는 지저분하지만, 이건 HTML과 거의 대등해요:
p("I am an ", strong("exciting"), " paragraph!")
현대적인 반응형 패러다임, 즉 UI를 단일 표현식으로 처음부터 구축해서 "빌드"하는 방식은 꽤 멀리 갈 수 있어요. 위 Flutter 예시의 관련 부분은 그냥 이거예요:
MaterialApp(
title: 'Welcome to Flutter',
home: Scaffold(
appBar: AppBar(title: Text('Welcome to Flutter')),
body: Column(
children: [
Text('Hello World'),
Text('This is Flutter'),
Text('Written in Dart')
]
)
)
)
꺾쇠 괄호 대신 괄호와 대괄호가 있지만, "마크업" 언어와 그리 멀지 않아요. 이것이 얼마나 잘 작동하는지는 놀라워요. Dart 문법은 JavaScript에 기반하고, JavaScript는 Java에서, Java는 C에서 왔어요. 그 과정에서 우리가 대괄호 리스트 리터럴 문법과 명명된 매개변수를 추가했지만, 그건 상당히 사소한 것이에요.
C는 PDP-11에서 명령줄 운영 체제를 구현하기 위해 설계됐어요. 그 표기법이 모바일 기기에서 그래픽 UI를 구축하는 데 그렇게 나쁘지 않게 확장된다는 사실은, Ritchie의 설계 취향을 증명하거나, 아니면 C 문법에 대한 우리 모두의 집단적 스톡홀름 증후군의 증거예요. 어느 쪽이든, 작동하긴 해요... 대부분은.
여기 예시에서는 UI를 구축하는 데 흥미로운 런타임 로직이 필요 없어요. 모든 것이 단일 중첩 표현식에 깔끔하게 들어가요. 하지만 어떤 이유로든 화요일에는 "This is Flutter" 텍스트 부분을 보여주고 싶지 않다고 해 봐요. (화면에 "Taco Tuesday!" 배너를 위한 공간을 확보해야 해서 그럴 수도 있죠.)
이를 표현하는 방법은 몇 가지가 있지만, 어떤 것도 위 예시만큼 멋지고 선언적으로 느껴지지 않아요. 한 가지 방법은 이거예요:
Widget build(BuildContext context) {
var texts = [Text('Hello World')];
if (!isTuesday) {
texts.add(Text('This is Flutter'));
}
texts.add(Text('Written in Dart'));
return MaterialApp(
title: 'Welcome to Flutter',
home: Scaffold(
appBar: AppBar(title: Text('Welcome to Flutter')),
body: Column(children: texts)
)
);
}
우리는 사람들을 템플릿으로 몰아넣는, 그 역겨운 저수준 명령형 코드에 훨씬 가까워졌어요. 실제 Flutter 코드를 살펴봤을 때, 이런 모습이 아주 많아서 슬펐어요. 그래서 약 1년 전, Flutter 팀이 Dart 팀에 Dart로 작성된 UI 코드를 더 쓰기 쉽고, 읽고, 유지보수하기 쉽게 만들 언어 변경을 제안해 달라고 요청했어요.
"코드로서의 UI (UI as Code)"
우리는 이 이니셔티브를 "UI as code"라고 불렀어요. 코드를 사용해서 UI를 구축하는 것이기 때문이에요. 하지만 궁극적인 목표는 Flutter든 아니든 가능한 한 많은 Dart 프로그램에 일반적으로 적용할 수 있는 언어 기능을 만드는 거예요. (더 많은 배경을 원한다면, 제가 쓴 긴 동기 부여 문서가 여기 있어요.)
많은 옵션을 탐색한 후, 우리는 컬렉션 리터럴을 중심으로 한 몇 가지 목표 지향적인 개선에 집중하기로 결정했어요. JSX 같은 걸 Dart에 우겨 넣는 것만큼 매력적으로 보이지 않을 수도 있지만(전혀 배제하는 건 아니지만요), 사용자가 코드에서 점진적으로 활용하기 훨씬 쉽다는 장점이 있어요.
그냥 리스트 리터럴을 더 멋지게 만드는 건, 음, 영향이 제한적으로 보일지도 몰라요. 하지만 위처럼 Flutter UI 코드를 보면, 기본적으로 생성자 호출과 리스트 리터럴의 커다란 트리예요. 리스트 리터럴은 그 영토의 큰 부분을 차지해요. (물론, 아예 언어 전체가 그걸 중심으로 설계된 경우도 있어요.) 선언적으로 쓸 수 있어야 할 것 같은데 대신 지저분한 명령형 변경을 해야 하는 예시를 파고들면, 아주 자주 리스트 주변에서 그런 상황을 만나게 돼요.
컬렉션을 더 좋게 만들 수 있다면, 많은 Dart 코드를 더 좋게 만들 수 있어요. 그를 위해 우리는 세 가지 새 기능을 추가하고 있어요.
스프레드(Spreads)
종종 위젯 리스트를 만들 때, 그 위젯 중 일부는 이미 다른 리스트에 들어 있어요. Flutter 코드 한 조각을 볼게요:
Widget build(BuildContext context) {
var conversation = buildTab2Conversation();
var listItems = [Tab2Header()];
listItems.addAll(conversation);
listItems.add(buildFooter());
return CupertinoPageScaffold(
child: ListView(children: conversation),
);
}
buildTab2Conversation() 메서드는 헤더와 푸터로 감싸고 싶은 위젯 리스트를 반환해요. 결과 리스트를 명령형으로 구축해야 하는 건 정말 짜증나요. 그걸로 코드를 "거꾸로" 읽어야 하게 돼요. children이 무엇의 children인지 알려주는 코드에 도달하기 전에 children을 어지럽히는 코드를 먼저 보게 되거든요.
Dart에는 메서드 캐스케이드(method cascades)라는 기능이 있어서 조금 도움이 돼요. 이것은 뮤테이팅 메서드 호출을 표현식 중간에 넣으면서 원래 객체를 이어주게 해 줘요. 그러면 이렇게 돼요:
Widget build(BuildContext context) {
return CupertinoPageScaffold(
child: ListView(children: [
Tab2Header()
]..addAll(buildTab2Conversation())
..add(buildFooter())),
);
}
그런 식으로 좀 낫지만, 여전히 꽤 어색해요. 단일 항목을 추가하는 그 trailing ..add()는 특히 거슬려요. 다른 여러 언어가 이미 같은 해결책을 갖고 있으니 우리가 어떻게 고쳤는지 짐작할 수 있을 거예요. (언어 설계의 90% 이상은 다른 언어에서 어떤 기능을 빌려올지 알아내는 거예요.) 우리는 스프레드(spreads)라는 새 문법을 추가하고 있어요.
컬렉션 리터럴 안에서, 스프레드는 다른 컬렉션을 풀어서 그 내용물을 바로 그 자리에 삽입해요. 예를 들어:
var a = [2, 3];
var b = [5, 6];
var c = [1, ...a, 4, ...b]; // [1, 2, 3, 4, 5, 6].
리스트 요소 앞의 ...는 그 요소들을 주변 리스트에 보간(interpolate)되게 해 줘요. 이것은 JavaScript가 사용하는 것과 같은 문법이에요. Python, Ruby 등 몇몇 언어는 같은 일에 접두 *를 사용하지만, 우리는 그것이 시각적으로 충분히 눈에 띄지 않는다고 느꼈어요. 이 기능으로 Flutter 예시는 이렇게 돼요:
Widget build(BuildContext context) {
return CupertinoPageScaffold(
child: ListView(children: [
Tab2Header(),
...buildTab2Conversation(),
buildFooter()
]),
);
}
이건 진정한 개선이라고 믿어요. 리스트 뷰의 모든 children이 단일 리스트 리터럴 안에 아늑하게 자리 잡아요. 더 보기 좋고, 타입 추론과도 더 잘 어울려요. 모든 요소가 리스트 안에 있으므로, 리스트 타입을 추론할 때 그것들 모두를 사용할 수 있어요.
여기서 Flutter 예시를 보여드리고 있지만, 저는 이 문법이 어디서 유용할지 보려고 방대한 Dart 코드 말뭉치를 샅샅이 뒤지는 데 많은 시간을 썼고, 이 문법은 곳곳에서 유용하게 쓰여요. 특히 다른 프로그램을 호출하기 위한 커맨드라인 인수 리스트를 만드는 코드는 스프레드에서 큰 이득을 봐요.
요소(Elements)
마지막 두 기능을 설명하기 전에, 스프레드가 실제로 무엇인지 파고들고 싶어요. 쓸데없이 오래 말하는 것처럼 보일 수도 있지만, 이걸 명확히 하는 게 나중에 도움이 될 거라고 장담해요. 선도 질문 하나: 스프레드는 표현식인가요?
표현식이 기대되는 위치의 리스트 리터럴에 나타나기 때문에 표현식처럼 보여요:
var stuff = [1, ...things, 4];
표현식처럼, 그것을 평가하면 어떤 데이터가 생성돼요. 어쩌면 Iterable 객체로 평가되는 표현식일까요? 잠깐, 그건 말이 안 돼요. 그게 스프레드 안의 표현식이 하는 일이잖아요. Iterable로 평가되는 표현식만 원한다면, 그 앞에 ...을 붙일 필요가 없어요.
스프레드는 단일 Iterable 객체로 평가되지 않아요. 그것은 그 객체를 풀어서, 그 Iterable이 만들어내는 일련의 객체들로 평가돼요. 그것을 다시 새 객체로 묶는 건 쓸모없을 거예요. 하지만 표현식은 항상 단일 객체로 평가돼요.
스프레드가 표현식이라면, 표현식이 허용되는 다른 위치에서 그것을 사용한다는 게 무슨 의미일까요?
var wat = ...things;
이건 뭘 할까요? 전체 Iterable을 wat에 객체로 저장하는 건 말이 안 돼요. 그걸 원한다면 ...을 아예 생략하면 되거든요. 답은 스프레드가 표현식이 아니라는 거예요. 스프레드는 다른 종류의 문법 범주예요. Dart는 많은 언어처럼 이미 문(statement)과 표현식(expression)이라는 두 개의 큰 문법 그룹을 가지고 있어요.
문은 실행되지만 결과 값을 만들어내지 않아요. 대신 유용한 부작용이 있으리라 기대돼요. 값이 필요한 어떤 상황에서도 문은 사용할 수 없어요. 값을 주지 않을 테니까요. 그게 바로 이런 게 금지되는 이유예요:
var wat = for (var i = 0; i < 10; i++) print(i);
for 문은 값을 만들어내지 않으므로, 변수 초기화자에 그것을 우겨 넣는 건 말이 안 돼요. 표현식과 문을 통합해서 이런 코드를 허용하는 언어도 있어요. 그 언어들은 각 문이 어떤 식으로든 실행되면서 값을 만들어내도록 정의해요. 하지만 Dart는 그런 언어가 아니에요.
표현식은 단일 결과 값으로 평가돼요. 값이 유용한 곳에서 사용할 수 있어요. "표현식 문(expression statement)" — 세미콜론이 따라오는 표현식 — 도 있는데, 이것은 단일 표현식을 담은 문이에요. 많은 표현식이 실제로 부작용을 가지므로, 결과가 필요 없을 때도 유용하다는 점에서 편리해요.
스프레드는 그 둘 모두가 아니에요. 스프레드는 0개의 값(빈 컬렉션을 스프레드하면), 1개의 값, 또는 많은 값으로 평가될 수 있어요. 그것은 그 자체만의 종류예요. 이 범주에 좋은 이름은 "제너레이터(generator)"일 거예요. 제 모델은 Icon에서 왔는데, 거기서는 모든 표현식이 제너레이터가 될 수 있어요. 하지만 Dart에는 이미 제너레이터 함수가 있으므로 그 용어를 과부하시키고 싶지 않았어요.
스프레드는 0개 이상의 값을 받는 것을 우아하게 처리할 수 있는 곳에만 나타날 수 있어요. 언어의 실행 모델을 완전히 뒤엎어서 Icon으로 바꾸지 않는 한(이건 제가 이상하게도 매력적으로 느끼지만 아마 실용적이진 않을 거예요), 그 제약을 충족하는 곳은 많지 않아요. 기본적으로 컬렉션 리터럴과 어쩌면 위치 인수 리스트 정도예요. (후자에 대한 제안서를 썼지만, 아주 복잡해서 우리는 하지 않기로 했어요. 적어도 지금은요.)
그러면 남는 것은 컬렉션 리터럴의 본문 안이에요. 그것에 기반해서, 저는 이것들을 "요소(element)"라고 불러요. 요소는 평가될 때 0개 이상의 값을 만들어내는 코드 조각이에요. 그러면 그 값들이 나타나는 주변 컨텍스트에 제자리에 보간돼요. 그래서 리스트에서는 새 리스트의 일련의 요소가 되고, 맵에서는 일련의 키/값 쌍이 돼요. 감이 오죠.
따라서 컬렉션 리터럴의 본문은 표현식 또는 요소를 담을 수 있어요. 두 범주를 허용하는 건 조금 혼란스럽지만, 다행히도 컬렉션이 요소만 담는다고 말함으로써 단순화할 수 있어요. 그러면 "표현식 요소(expression element)"를 단일 표현식을 담은 요소로 정의해요. 요소는 항상 하나의 결과 — 표현식의 값 — 을 만들어내요. 표현식 문에 해당하는 요소 버전인 셈이죠.
좋아요, 그러니 우리가 있는 곳은 이 정도예요. 우리는 컬렉션이 표현식 대신 요소를 담도록 바꿨고, 스프레드와 표현식 요소 두 종류의 요소를 정의했어요. 컬렉션 리터럴을 평가하려면 요소들을 순회하며 각각을 평가하고, (셋의 경우 유니온하거나) 결과 객체들을 모두 이어붙여요.
이 모델을 염두에 두고, 나머지 두 개의 새 기능을 살펴볼 수 있어요.
컬렉션 If (Collection If)
Flutter 코드를 어떤 것이든 작성하다 보면, 만들고 싶은 위젯 트리가 어떤 조건에 따라 변하는 경우를 꽤 빨리 만나게 돼요. 이게 있다고 해 봐요:
Widget build(BuildContext context) {
return Row(
children: [
IconButton(icon: Icon(Icons.menu)),
Expanded(child: title),
IconButton(icon: Icon(Icons.search)),
],
);
}
나중에, Android에서는 다른 검색 버튼을 사용하고 싶다고 결정했어요. 조건부 표현식을 사용해서 이미 그렇게 할 수 있어요:
Widget build(BuildContext context) {
return Row(
children: [
IconButton(icon: Icon(Icons.menu)),
Expanded(child: title),
isAndroid
? TextButton("Search")
: IconButton(icon: Icon(Icons.search)),
],
);
}
이건 그럭저럭 작동해요. 다만 C의 조건부 연산자는 읽기 매우 쉽다고 느낀 적이 없어요. 하지만 종종 원하는 것은 조건에 따라 위젯을 교체하는 게 아니라, 하나를 그냥 생략하는 거예요. Android에서 검색 상자를 아예 보여주고 싶지 않다고 해 봐요. 지금 Flutter 사용자들은 보통 두 패턴 중 하나를 사용해요. 첫 번째는 이거예요:
Widget build(BuildContext context) {
var children = [
IconButton(icon: Icon(Icons.menu)),
Expanded(child: title)
];
if (!isAndroid) {
children.add(IconButton(icon: Icon(Icons.search)));
}
return Row(children: children);
}
이것은 child 리스트를 끌어올리고, 사용하기 전에 명령형으로 구축하면서 함수 전체를 재배열하게 강제해요. 다른 패턴은 이렇게 생겼어요:
Widget build(BuildContext context) {
return Row(
children: [
IconButton(icon: Icon(Icons.menu)),
Expanded(child: title),
!isAndroid ? IconButton(icon: Icon(Icons.search)) : null,
].where((widget) => widget != null).toList(),
);
}
이것은 때때로 null을 만드는 조건부 표현식을 사용한 다음, 결과 리스트에서 그 null을 걸러내요. 이것을 고안한 사람에게 존경을 표하지만, 이런 단순한 작업을 하기 위해 사용자가 그런 것을 작성해야 해선 안 돼요. 단순한 문제에는 단순한 해결책이 있어야 하고, 프로그램에 대한 작은 개념적 변경이 큰 텍스트 변경을 요구해서는 안 돼요.
이제 새로 생긴 것이에요. Android에서 다른 버튼을 원하는 첫 번째 예시는 이렇게 보여요:
Widget build(BuildContext context) {
return Row(
children: [
IconButton(icon: Icon(Icons.menu)),
Expanded(child: title),
if (isAndroid)
TextButton("Search")
else
IconButton(icon: Icon(Icons.search)),
],
);
}
?: 대신, 익숙한 if와 else 문법을 사용해요. 이것은 문자 그대로 기존 조건부 표현식과 몇 개의 토큰만 다를 뿐이라, 그 가치를 지니지 못하는 것 같아요. 더 흥미로운 경우는 Android에서 버튼을 생략하고 싶을 때예요:
Widget build(BuildContext context) {
return Row(
children: [
IconButton(icon: Icon(Icons.menu)),
Expanded(child: title),
if (!isAndroid)
IconButton(icon: Icon(Icons.search)),
],
);
}
else 절이 없다는 점에 주목하세요. 이 두 예시 모두 if가 표현식인 Ruby 같은 언어와 상당히 비슷해 보여요. 하지만 표현식은 조건이 거짓일 때조차 항상 값으로 평가되어야 해요. Ruby에서는 그 경우 암시적으로 nil로 평가돼요.
하지만 그건 여기서 원하는 게 아니에요. child 위젯 리스트에 null 요소가 생기길 원하지 않아요. 그래서 위의 조건부 표현식 예시에서 성가신 where()을 사용해서 걸러내야 했던 거예요. 다행히도 여기서는 그게 문제가 아니에요. 컬렉션 안의 if는 표현식이 아니기 때문이에요. 그것은 요소예요.
이제 제가 왜 스프레드로 여러분을 끌고 다녔는지 알겠죠. 요소는 컬렉션에서 요소를 완전히 생략할 수 있게 하는 if 문법을 가지기 위한 기반을 줘요. if 요소는 조건이 참이거나 else 케이스가 있으면 단일 값을 만들어내요. 조건이 거짓이고 else 절이 없으면, 그냥 값을 전혀 만들어내지 않아요.
이 동작이 정말 유용하다고 생각하지만, 코드를 보고 if가 단순 표현식처럼 행동하길 기대하면 헷갈릴 수도 있어요.
컬렉션 For (Collection For)
이전 기능은 기존 Dart 문 문법 조각을 가져다가 컬렉션 컨텍스트에서 유용한 일을 하도록 재용도해요. 가져올 만한 다른 문 형식이 있을까요?
대부분은 말이 안 돼요. 그 안에 return 문을 끼워 넣는 건 주변 함수를 그냥 빠져나가므로 쓸모있는 일을 하지 않아요. while도 아주 유용하진 않아요. while 루프를 빠져나가려면 보통 본문에 break, return 또는 할당 같은 어떤 부작용이 들어 있어야 해요. 하지만 그건 본문에 문이 들어 있다는 뜻인데, 우리가 원하는 게 아니에요. 목표는 컬렉션을 더 명령형으로 만드는 게 아니라 더 표현력 있게 만드는 거예요.
저는 기존 코드의 컬렉션 리터럴 여러 개를 훑어보며 새 문법으로 개선될 수 있다고 생각되는 패턴을 찾았어요. 압도적으로 가장 많은 것은 if였어요. 하지만 for로 개선될 수 있다고 생각되는 곳도 여러 개 봤어요. 찾은 코드의 약간 정리된 예시는 이거예요:
var command = [
engineDartPath,
frontendServer,
];
for (var root in fileSystemRoots) {
command.add("--filesystem-root=$root");
}
for (var entryPoint in entryPoints) {
command.add("$entryPoint.json");
}
command.add(mainPath);
command.add() 같은 것들이 전부 불필요하게 명령형으로 느껴져요. 컬렉션 리터럴 안에서 for 루프를 허용하면, 이렇게 돼요:
var command = [
engineDartPath,
frontendServer,
for (var root in fileSystemRoots) "--filesystem-root=$root",
for (var entryPoint in entryPoints) "lib/$entryPoint",
mainPath
];
요소 합성하기 (Composing Elements)
이미 스프레드를 추가하고 있으니, for 문법은 그렇게 매력적으로 보이지 않을 거예요. iterable에 대한 고차 메서드들의 어떤 조합과 함께 스프레드를 사용해도 같은 일을 할 수 있지 않나요? 네, 할 수 있어요. 이렇게 될 거예요:
var command = [
engineDartPath,
frontendServer,
...fileSystemRoots.map((root) => "--filesystem-root=$root"),
...entryPoints.map((entryPoint) => "lib/$entryPoint"),
mainPath
];
이건 작동하고 어떤 사용 사례에는 괜찮아요. 약간 다른 예시를 생각해 봐요. 해당 JSON 파일이 존재할 때만 entry point를 포함하고 싶다고 해 봐요. 그 말은 단순한 1–1 매핑을 하고 있지 않다는 뜻이에요. 스프레드만 사용하면 이렇게 될 거예요:
var command = [
engineDartPath,
frontendServer,
...fileSystemRoots.map((root) => "--filesystem-root=$root"),
...entryPoints
.where((entryPoint) => fileExists("lib/$entryPoint.json"))
.map((entryPoint) => "lib/$entryPoint"),
mainPath
];
이것도 작동해요. 하지만 "파일이 존재하면 이렇게 하라"는 단순한 로직을 스트림 기반의 고차 함수형 스타일로 옮기는 게 점점 더 어려워지기 시작해요. 항상 map(), where(), 그리고 어쩌면 transform()의 어떤 조합으로 해결되지만, 하이쿠를 역 폴란드 표기법으로 번역하는 것처럼 느껴질 수 있어요.
더 깔끔한 해결책이 있고, 여기서 핵심 질문이 등장해요. 이 새 if와 for 요소의 본문은 무엇일까요? 지금까지 보여드린 예시에서는 항상 표현식이었어요. 하지만 그것으로 제한할 필요는 없어요. 대신, 어떤 요소든 그곳에 갈 수 있도록 해요. 다시 말해, 이 세 가지 새 기능 모두가 자유롭게 합성될 수 있어요. 위 코드는 이렇게 표현할 수 있어요:
var command = [
engineDartPath,
frontendServer,
for (var root in fileSystemRoots) "--filesystem-root=$root",
for (var entryPoint in entryPoints)
if (fileExists("lib/$entryPoint.json")) "lib/$entryPoint",
mainPath
];
명령형 문을 쓸 때처럼 하는, for 안의 단순한 if예요. 요소를 합성하는 의미론은 꽤 명확한 것으로 드러나요.
- if 요소는 조건이 참이면 then 절이 만드는 모든 값을, 그렇지 않으면 "else" 절의 모든 요소를 만들어내요. "else"가 없으면 요소를 만들지 않아요.
- for 요소는 본문이 실행될 때마다 본문 요소가 만드는 모든 값을 연결한 것을 만들어내요.
이것은 제 생각에 멋진 몇 가지 패턴을 가능하게 해요. 흔히 마주치는 문제는 단일 조건에 기반해서 여러 값을 포함하거나 생략하고 싶다는 거예요. 그래서 이전 예시에서 Android에서 제목과 검색 상자 둘 다를 건너뛰고 싶다고 해 봐요. if 안에 스프레드를 감싸서 그렇게 할 수 있어요:
Widget build(BuildContext context) {
return Row(
children: [
IconButton(icon: Icon(Icons.menu)),
if (!isAndroid) ...[
Expanded(child: title),
IconButton(icon: Icon(Icons.search)),
]
],
);
}
여기서 안쪽 리스트를 풀려면 스프레드가 필요해요. 그렇지 않으면, Android가 아닐 때 전체 안쪽 리스트를 단일 값으로 포함하게 될 거예요. (이런 경우 정적 타입에 기반해 암시적으로 평탄화하는 것도 고려했지만, List
리스트 리터럴을 스프레드하는 것은 문에 대한 중괄호 블록에 해당하는 요소 버전이라고 생각하면 돼요. 하나만 기대되는 곳에 여러 요소를 넣을 수 있게 해 주죠. (콤마 연산자에 익숙하다면, 그건 본질적으로 표현식에 대한 유사 형태예요. 곳곳에 평행 구조가 있죠.)
빈 컬렉션 리터럴 안에서 for와 if를 사용하면, Python 같은 일부 언어가 지원하는 특별한 "리스트 컴프리헨션" 문법과 그리 멀지 않은 문법을 얻을 수 있어요:
[for (var i = 1; i <= 5; i++) if (i.isOdd) i * i] // [1, 9, 25].
for를 중첩할 수도 있어요:
[for (var x in hor) for (var y in vert) Point(x, y)]
이것은 주어진 hor과 vert 사각형 안의 모든 점들의 데카르트 곱(Cartesian product)을 담은 리스트를 만들어요.
또한 이런 새 기능들은 컬렉션 타입들을 가로질러 합성돼요. 저는 예시로 리스트를 계속 사용해 왔는데, 가장 자주 등장하기 때문이에요. 하지만 이 모든 기능은 맵과 셋 안에서도 작동해요. 유일한 차이는 셋에서는 중복이 암시적으로 버려진다는 것, 그리고 맵에서는 원시 표현식 요소 대신 기본 요소가 키/값 쌍이라는 거예요. 예를 들어, 이 코드:
Map<String, WidgetBuilder>.fromIterable(
allGalleryDemos.where((demo) => demo.exists),
key: (demo) => '${demo.routeName}',
value: (demo) => demo.buildRoute,
);
이렇게 다시 쓸 수 있어요:
{
for (var demo in allGalleryDemos)
if (demo.exists) '${demo.routeName}': demo.buildRoute,
};
이 모든 기능에 대해 우리가 가진 진짜 우려 하나는, 본질적으로 오늘 이미 표현할 수 있는 것들을 표현할 수 있는 새로운 방법을 드리고 있다는 거예요. 이것은 비용이 있어요. 사용자들이 어떤 기능을 사용할지 결정하는 데 두뇌 에너지를 써야 하고, 다른 사람의 코드를 읽을 때 왜 한 옵션이 다른 옵션보다 선택됐는지 의문을 품는 데 시간을 쓸 수도 있기 때문이에요. 배울 기능이 그저 더 많아지고 언어가 더 커져요.
우리는 이것에 대해 오랫동안 애태우며 고민했어요. 때로는 아무것도 하지 않는 것이 최고의 설계예요. 단순함은 많은 가치를 지니고, 시간이 지나며 언어를 더 단순하게 만들 기회를 얻는 경우는 드물어요. 하지만 많은 코드를 살펴보고 훌륭한 UX 리서처와 함께 연구를 수행한 결과, 이 기능들이 가볍고 충분히 유용해서 그 가치를 지닐 만하다고 꽤 확신해요.
어떤 언어 변경이든, 실제로 사용자 손에 들어가기 전에는 어떻게 될지 절대 알 수 없어요. 이 기능들은 곧 출시될 Dart 2.3 릴리스에서 여러분 손에 닿을 거예요. 여러분이 이것으로 무엇을 만들어낼지 정말 기대돼요.