Effective Dart: 디자인(Design)
Effective Dart: 디자인(Design)
일관되고 사용하기 좋은 라이브러리를 설계해볼게요.
출처: 원문
본문
라이브러리를 위한 일관되고 사용하기 좋은 API를 작성하기 위한 지침이에요.
이름(Names)
명명은 읽기 쉽고 유지보수하기 쉬운 코드를 쓰는 중요한 부분이에요. 다음 모범 사례가 그 목표를 이루는 데 도움이 될 수 있어요.
DO: 용어를 일관되게 사용해요
코드 전체에서 같은 것에 같은 이름을 사용해 주세요. 사용자가 알 법한 선례가 API 밖에 이미 존재한다면, 그 선례를 따르세요.
// 좋음
pageCount // A field.
updatePageCount() // Consistent with pageCount.
toSomething() // Consistent with Iterable's toList().
asSomething() // Consistent with List's asMap().
Point // A familiar concept.
// 나쁨
renumberPages() // Confusingly different from pageCount.
convertToSomething() // Inconsistent with toX() precedent.
wrappedAsSomething() // Inconsistent with asX() precedent.
Cartesian // Unfamiliar to most users.
목표는 사용자가 이미 아는 것을 활용하는 것이에요. 여기에는 문제 도메인 자체에 대한 지식, 핵심 라이브러리의 관례, 그리고 API의 다른 부분들이 포함돼요. 그것들 위에 구축하면, 사용자가 생산적으로 되기 위해 새로 습득해야 하는 지식의 양을 줄일 수 있어요.
AVOID: 약어를 사용하지 마세요
약어가 축약되지 않은 용어보다 더 흔하지 않는 한, 약어를 쓰지 마세요. 약어를 쓴다면 올바르게 대문자화해 주세요.
// 좋음
pageCount
buildRectangles
IOStream
HttpRequest
// 나쁨
numPages // "Num" is an abbreviation of "number (of)".
buildRects
InputOutputStream
HypertextTransferProtocolRequest
PREFER: 가장 설명적인 명사를 마지막에 배치해요
마지막 단어는 그것이 무엇인지 가장 설명적으로 나타내야 해요. 형용사 같은 다른 단어를 앞에 붙여 그 대상을 더 설명할 수 있어요.
// 좋음
pageCount // A count (of pages).
ConversionSink // A sink for doing conversions.
ChunkedConversionSink // A ConversionSink that's chunked.
CssFontFaceRule // A rule for font faces in CSS.
// 나쁨
numPages // Not a collection of pages.
CanvasRenderingContext2D // Not a "2D".
RuleFontFaceCss // Not a CSS.
CONSIDER: 코드가 문장처럼 읽히도록 하는 것을 고려해요
명명이 의심스러울 때, API를 사용하는 코드를 몇 개 쓰고 그것을 문장처럼 읽어 보세요.
// 좋음
// "If errors is empty..."
if (errors.isEmpty) { ... }
// "Hey, subscription, cancel!"
subscription.cancel();
// "Get the monsters where the monster has claws."
monsters.where((monster) => monster.hasClaws);
// 나쁨
// Telling errors to empty itself, or asking if it is?
if (errors.empty) { ... }
// Toggle what? To what?
subscription.toggle();
// Filter the monsters with claws *out* or include *only* those?
monsters.filter((monster) => monster.hasClaws);
API를 시험해 보고 코드에서 사용될 때 어떻게 "읽히는지" 보는 것은 도움이 되지만, 너무 멀리 갈 수 있어요. 이름이 문자 그대로 문법적으로 올바른 문장처럼 읽히도록 관사와 다른 품사를 추가하는 것은 도움이 되지 않아요.
// 나쁨
if (theCollectionOfErrors.isEmpty) { ... }
monsters.producesANewSequenceWhereEach((monster) => monster.hasClaws);
PREFER: 불리언이 아닌 프로퍼티나 변수에는 명사구를 사용해요
독자의 초점은 프로퍼티가 무엇인지에 있어요. 사용자가 프로퍼티가 어떻게 결정되는지 더 신경 쓴다면, 그것은 아마 동사구 이름을 가진 메서드여야 해요.
// 좋음
list.length
context.lineWidth
quest.rampagingSwampBeast
// 나쁨
list.deleteItems
PREFER: 불리언 프로퍼티나 변수에는 비명령형 동사구를 사용해요
불리언 이름은 흐름 제어에서 조건으로 자주 사용되므로, 거기서 잘 읽히는 이름을 원해요. 비교해 보세요.
if (window.closeable) ... // Adjective.
if (window.canClose) ... // Verb.
좋은 이름은 몇 종류의 동사 중 하나로 시작하는 경향이 있어요.
- "to be"의 한 형태:
isEnabled,wasShown,willFire. 이것들이 압도적으로 가장 흔해요. - 조동사(auxiliary verb):
hasElements,canClose,shouldConsume,mustSave. - 능동 동사(active verb):
ignoresInput,wroteFile. 이것들은 보통 모호하기 때문에 드물어요.loggedResult는 "결과가 로그되었는지 여부" 또는 "로그된 결과"를 뜻할 수 있으므로 좋지 않은 이름이에요. 마찬가지로closingConnection은 "연결이 닫히고 있는지" 또는 "닫히고 있는 연결"일 수 있어요. 이름이 술어(predicate)로만 읽힐 수 있을 때 능동 동사가 허용돼요.
이 모든 동사구를 메서드 이름과 구분 짓는 것은 그것들이 명령형이 아니라는 점이에요. 불리언 이름은 객체에 무언가 하라고 명령하는 것처럼 들려서는 안 돼요. 프로퍼티에 접근하는 것은 객체를 바꾸지 않으니까요. (프로퍼티가 객체를 의미 있게 수정한다면 메서드여야 해요.)
// 좋음
isEmpty
hasElements
canClose
closesWindow
canShowPopup
hasShownPopup
// 나쁨
empty // Adjective or verb?
withElements // Sounds like it might hold elements.
closeable // Sounds like an interface.
// "canClose" reads better as a sentence.
closingWindow // Returns a bool or a window?
showPopup // Sounds like it shows the popup.
CONSIDER: 이름 붙은 불리언 매개변수에서 동사를 생략하는 것을 고려해요
이것은 이전 규칙을 다듬은 것이에요. 불리언인 이름 붙은 매개변수의 경우, 동사 없이도 이름이 종종 똑같이 명확하고 호출 지점에서 코드가 더 잘 읽혀요.
// 좋음
Isolate.spawn(entryPoint, message, paused: false);
var copy = List.from(elements, growable: true);
var regExp = RegExp(pattern, caseSensitive: false);
PREFER: 불리언 프로퍼티나 변수에는 "긍정적" 이름을 사용해요
대부분의 불리언 이름은 개념적으로 "긍정적"과 "부정적" 형태를 가져요. 전자가 근본 개념처럼 느껴지고 후자는 그 부정이에요. "open"과 "closed", "enabled"와 "disabled"처럼요. 후자의 이름은 종종 문자 그대로 전자를 부정하는 접두사를 가져요. "visible"과 "in-visible", "connected"와 "dis-connected", "zero"와 "non-zero"처럼요.
true가 두 경우 중 어느 쪽을 나타내는지 선택할 때(따라서 프로퍼티가 어느 쪽으로 이름 지어질지), 긍정적이거나 더 근본적인 쪽을 선호해 주세요. 불리언 멤버는 부정 연산자를 포함한 논리 표현식 안에 중첩되는 경우가 많아요. 프로퍼티 자체가 부정처럼 읽히면, 독자가 이중 부정을 머릿속으로 수행하고 코드가 무엇을 뜻하는지 이해하기 더 어려워요.
// 좋음
if (socket.isConnected && database.hasData) {
socket.write(database.read());
}
// 나쁨
if (!socket.isDisconnected && !database.isEmpty) {
socket.write(database.read());
}
어떤 프로퍼티에는 명백한 긍정 형태가 없어요. 디스크에 플러시된 문서는 "saved"인가 "un-changed"인가? 플러시되지 않은 문서는 "un-saved"인가 "changed"인가? 모호한 경우에는 사용자가 부정할 가능성이 덜하거나 이름이 더 짧은 쪽을 선택하는 쪽으로 기울이세요.
예외: 어떤 프로퍼티에서는 부정 형태가 사용자가 압도적으로 사용해야 하는 것이에요. 긍정 형태를 선택하면 사용자가 사방에서 !로 프로퍼티를 부정해야 해요. 그런 경우 그 프로퍼티에는 부정 형태를 사용하는 것이 더 좋을 수 있어요.
PREFER: 주요 목적이 부수 효과(side effect)인 함수나 메서드에는 명령형 동사구를 사용해요
호출 가능한 멤버는 호출자에게 결과를 반환하고 다른 작업이나 부수 효과를 수행할 수 있어요. Dart 같은 명령형 언어에서 멤버는 종종 부수 효과 때문에 호출돼요. 객체의 내부 상태를 바꾸거나, 어떤 출력을 만들거나, 외부 세계와 소통할 수 있어요.
그런 종류의 멤버는 멤버가 수행하는 작업을 명확히 하는 명령형 동사구로 이름 지어야 해요.
// 좋음
list.add('element');
queue.removeFirst();
window.refresh();
이렇게 하면 호출이 그 작업을 하라는 명령처럼 읽혀요.
PREFER: 값 반환이 주요 목적이라면 함수나 메서드에 명사구나 비명령형 동사구를 사용해요
다른 호출 가능한 멤버는 부수 효과가 거의 없지만 호출자에게 유용한 결과를 반환해요. 그런 작업을 하는 데 매개변수가 필요하지 않다면 보통 게터여야 해요. 하지만 때로 논리적 "프로퍼티"가 매개변수를 필요로 해요. 예를 들어 elementAt()은 컬렉션에서 데이터 조각을 반환하지만, 어느 데이터 조각을 반환할지 알려면 매개변수가 필요해요.
이것은 멤버가 구문상으로는 메서드지만 개념적으로는 프로퍼티이며, 멤버가 반환하는 것을 설명하는 구로 그렇게 이름 지어야 한다는 뜻이에요.
// 좋음
var element = list.elementAt(3);
var first = list.firstWhere(test);
var char = string.codeUnitAt(4);
이 지침은 의도적으로 이전 지침보다 부드러워요. 때로 어떤 메서드는 부수 효과가 없어도 list.take()나 string.split() 같은 동사구로 이름 짓는 것이 여전히 더 단순해요.
CONSIDER: 수행하는 작업에 주의를 끌고 싶다면 함수나 메서드에 명령형 동사구를 고려해요
멤버가 부수 효과 없이 결과를 만들어낼 때는 보통 게터이거나 반환하는 결과를 설명하는 명사구 이름의 메서드여야 해요. 하지만 때로 그 결과를 만드는 데 필요한 작업이 중요할 수 있어요. 런타임 실패가 발생하기 쉬울 수 있거나, 네트워킹이나 파일 I/O 같은 무거운 리소스를 사용할 수 있어요. 호출자가 멤버가 하는 작업에 대해 생각하게 하고 싶은 그런 경우, 그 작업을 설명하는 동사구 이름을 멤버에 주세요.
// 좋음
var table = database.downloadData();
var packageVersions = packageGraph.solveConstraints();
다만 이 지침은 이전 두 지침보다 부드럽다고 유의해 주세요. 연산이 수행하는 작업은 호출자와 무관한 구현 세부 사항인 경우가 많고, 성능과 견고성 경계는 시간이 지나며 변해요. 대부분 멤버의 이름은 어떻게 하는지가 아니라 호출자에게 무엇을 해주는지에 따라 지으세요.
AVOID: 함수나 메서드 이름을 get으로 시작하지 마세요
대부분의 경우 메서드나 함수는 이름에서 get을 제거한 게터여야 해요. 예를 들어 getBreakfastOrder()라는 메서드 대신 breakfastOrder라는 게터를 정의해 주세요.
멤버가 인자를 받거나 게터에 어울리지 않아서 메서드여야 해도, 여전히 get을 피해야 해요. 이전 지침들이 말하듯, 둘 중 하나를 선택해 주세요.
- 호출자가 메서드가 반환하는 값에 대부분 신경 쓴다면 그냥
get을 버리고breakfastOrder()같은 명사구 이름을 사용해 주세요. - 호출자가 수행되는 작업에 신경 쓴다면 동사구 이름을 사용하되,
get보다 그 작업을 더 정확히 설명하는 동사(create,download,fetch,calculate,request,aggregate등)를 골라 주세요.
PREFER: 객체의 상태를 새 객체로 복사하는 메서드 이름은 to___()로 지어요
린터 규칙: use_to_and_as_if_applicable
변환(conversion) 메서드는 수신자(receiver)의 거의 모든 상태의 복사본을 담고 있지만 보통 어떤 다른 형태나 표현으로 된 새 객체를 반환하는 메서드예요. 핵심 라이브러리에는 이 메서드들이 to에 이어 결과의 종류로 이름 짓는다는 관례가 있어요.
변환 메서드를 정의한다면 그 관례를 따르는 것이 도움이 돼요.
// 좋음
list.toSet();
stackTrace.toString();
dateTime.toLocal();
PREFER: 원본 객체에 기반한 다른 표현을 반환하는 메서드 이름은 as___()로 지어요
린터 규칙: use_to_and_as_if_applicable
변환 메서드는 "스냅샷"이에요. 결과 객체는 원본 객체 상태의 자체 복사본을 가져요. 뷰(view, 보기)를 반환하는 다른 변환류 메서드도 있어요. 그것들은 새 객체를 제공하지만, 그 객체는 원본을 다시 가리켜요. 원본 객체에 대한 이후 변경은 뷰에 반영돼요.
따를 핵심 라이브러리 관례는 as___()예요.
// 좋음
var map = table.asMap();
var list = bytes.asFloat32List();
var future = subscription.asFuture();
AVOID: 함수나 메서드 이름에 매개변수를 설명하지 마세요
사용자는 호출 지점에서 인자를 볼 수 있으므로, 그것을 이름 자체에서도 언급하는 것은 가독성에 도움이 되지 않아요.
// 좋음
list.add(element);
map.remove(key);
// 나쁨
list.addElement(element)
map.removeKey(key)
다만 다른 타입을 받는 비슷한 이름의 메서드들과 구분하기 위해 매개변수를 언급하는 것은 유용할 수 있어요.
// 좋음
map.containsKey(key);
map.containsValue(value);
DO: 타입 매개변수 이름을 지을 때 기존의 연상 기호 규약을 따르세요
한 글자 이름은 정확히 밝히는 것이 아니지만, 거의 모든 제네릭 타입이 그것을 사용해요. 다행히 대부분 일관되고 연상적인 방식으로 사용해요. 관례는 다음과 같아요.
- 컬렉션의 요소 타입에는
E:class IterableBase<E> {} class List<E> {} class HashSet<E> {} class RedBlackTree<E> {} - 연관 컬렉션의 키와 값 타입에는
K와V:class Map<K, V> {} class Multimap<K, V> {} class MapEntry<K, V> {} - 함수나 클래스 메서드의 반환 타입으로 쓰이는 타입에는
R. 드물지만 typedef나 visitor 패턴을 구현하는 클래스에 가끔 나타나요:abstract class ExpressionVisitor<R> { R visitBinary(BinaryExpression node); R visitLiteral(LiteralExpression node); R visitUnary(UnaryExpression node); } - 그 외에는, 단일 타입 매개변수를 갖고 주변 타입이 그 의미를 명백히 하는 제네릭에
T,S,U를 사용해요. 여기 여러 글자가 있는 것은 둘러싼 이름을 가리지 않고 중첩을 허용하기 위해서예요. 예를 들어:
여기서 제네릭 메서드class Future<T> { Future<S> then<S>(FutureOr<S> onValue(T value)) => ... }then<S>()는Future<T>의T를 가리지 않도록S를 사용해요.
위 경우가 없으면, 다른 한 글자 연상 이름이나 설명적인 이름이 괜찮아요.
class Graph<N, E> {
final List<N> nodes = [];
final List<E> edges = [];
}
class Graph<Node, Edge> {
final List<Node> nodes = [];
final List<Edge> edges = [];
}
실제로는 기존 관례가 대부분의 타입 매개변수를 다뤄요.
라이브러리(Libraries)
앞 밑줄 문자(_)는 멤버가 그것의 라이브러리에 비공개임을 나타내요. 이것은 단순한 관례가 아니라 언어 자체에 내장되어 있어요.
PREFER: 선언을 비공개로 만드는 것을 선호해요
라이브러리의 공개 선언(최상위든 클래스 안이든)은 다른 라이브러리가 그 멤버에 접근할 수 있고 접근해야 한다는 신호예요. 그것은 또한 여러분 라이브러리가 그것을 지원하고 그럴 때 올바르게 동작한다는 약속이에요.
그게 의도가 아니라면, 작은 _를 붙이고 만족해 주세요. 좁은 공개 인터페이스는 여러분이 유지보수하기도, 사용자가 배우기도 더 쉬워요. 보너스로 분석기(analyzer)는 사용되지 않는 비공개 선언을 알려줘서 죽은 코드를 삭제할 수 있게 해줘요. 멤버가 공개면 그것은 할 수 없어요. 자기 시야 밖의 어떤 코드가 그것을 사용하는지 알 수 없으니까요.
CONSIDER: 같은 라이브러리에 여러 클래스를 선언하는 것을 고려해요
Java 같은 일부 언어는 파일 구성을 클래스 구성과 묶어요. 각 파일은 단일 최상위 클래스만 정의할 수 있어요. Dart에는 그 제한이 없어요. 라이브러리는 클래스와 분리된 구분되는 개체예요. 단일 라이브러리가 논리적으로 함께 속하는 여러 클래스, 최상위 변수, 함수를 포함하는 것은 완전히 괜찮아요.
여러 클래스를 한 라이브러리에 함께 배치하면 유용한 패턴이 가능해져요. Dart에서 비공개가 클래스 수준이 아니라 라이브러리 수준에서 작동하기 때문에, 이것은 C++에서처럼 "friend" 클래스를 정의하는 방법이에요. 같은 라이브러리에 선언된 모든 클래스는 서로의 비공개 멤버에 접근할 수 있지만, 그 라이브러리 밖의 코드는 그럴 수 없어요.
물론 이 지침이 모든 클래스를 거대한 단일체 라이브러리에 넣으라는 뜻은 아니에요. 단지 하나의 라이브러리에 두 개 이상의 클래스를 배치할 수 있다는 뜻이에요.
클래스와 믹스인(Classes and mixins)
Dart는 모든 객체가 클래스의 인스턴스라는 점에서 "순수" 객체지향 언어예요. 하지만 Dart는 모든 코드가 클래스 안에 정의되어야 한다고 요구하지 않아요. 절차적이거나 함수형 언어처럼 최상위 변수, 상수, 함수를 정의할 수 있어요.
AVOID: 단순한 함수로 충분할 때 멤버가 하나뿐인 추상 클래스를 정의하지 마세요
린터 규칙: one_member_abstracts
Java와 달리 Dart는 일급 함수(first-class functions), 클로저, 그리고 그것들을 사용하는 간결한 구문을 가져요. 콜백 같은 것이 필요하다면 그냥 함수를 사용해 주세요. 클래스를 정의하는데 call이나 invoke 같은 의미 없는 이름의 추상 멤버가 하나만 있다면, 함수를 원하는 것일 가능성이 커요.
// 좋음
typedef Predicate<E> = bool Function(E element);
// 나쁨
abstract class Predicate<E> {
bool test(E element);
}
AVOID: 정적(static) 멤버만 포함하는 클래스를 정의하지 마세요
린터 규칙: avoid_classes_with_only_static_members
Java와 C#에서는 모든 정의가 클래스 안에 있어야 하므로, 정적 멤버를 채워 넣을 자리로만 존재하는 "클래스"가 흔해요. 다른 클래스들은 네임스페이스, 즉 멤버 그룹을 서로 연관시키거나 이름 충돌을 피하기 위해 공유 접두사를 주는 방법으로 사용돼요.
Dart에는 최상위 함수, 변수, 상수가 있으므로 무언가를 정의하기 위해 클래스가 필요하지 않아요. 네임스페이스를 원한다면 라이브러리가 더 잘 맞아요. 라이브러리는 import 접두사와 show/hide 조합자를 지원해요. 그것들은 코드 소비자가 이름 충돌을 자기에게 가장 잘 맞는 방식으로 처리하게 하는 강력한 도구예요.
함수나 변수가 논리적으로 클래스에 묶이지 않는다면 최상위에 두세요. 이름 충돌이 걱정된다면 더 정밀한 이름을 주거나, 접두사로 import할 수 있는 별도 라이브러리로 옮기세요.
// 좋음
DateTime mostRecent(List<DateTime> dates) {
return dates.reduce((a, b) => a.isAfter(b) ? a : b);
}
const _favoriteMammal = 'weasel';
// 나쁨
class DateUtils {
static DateTime mostRecent(List<DateTime> dates) {
return dates.reduce((a, b) => a.isAfter(b) ? a : b);
}
}
class _Favorites {
static const mammal = 'weasel';
}
관용적인 Dart에서 클래스는 객체의 종류를 정의해요. 결코 인스턴스화되지 않는 타입은 코드 냄새(code smell)예요.
하지만 이것은 엄격한 규칙은 아니에요. 예를 들어 상수와 enum류 타입은 클래스로 그룹화하는 것이 자연스러울 수 있어요.
// 좋음
class Color {
static const red = '#f00';
static const green = '#0f0';
static const blue = '#00f';
static const black = '#000';
static const white = '#fff';
}
AVOID: 서브클래싱을 의도하지 않은 클래스를 확장하지 마세요
생성자가 generative 생성자에서 factory 생성자로 바뀌면, 그 생성자를 호출하는 어떤 서브클래스 생성자도 깨져요. 또한 클래스가 자신의 어떤 메서드를 this로 호출하는 방식을 바꾸면, 그 메서드를 오버라이드하고 특정 시점에 호출되기를 기대하는 서브클래스를 깨뜨릴 수 있어요.
둘 다 클래스가 서브클래싱을 허용할지 여부에 대해 신중해야 한다는 뜻이에요. 이것은 문서 주석으로 전달하거나, IterableBase 같은 명백한 이름을 주어 전달할 수 있어요. 클래스 작성자가 그렇게 하지 않았다면, 클래스를 확장하지 않는 것이 최선이에요. 그렇지 않으면 나중에 클래스가 바뀌면 여러분의 코드가 깨질 수 있어요.
DO: 클래스가 확장 가능한지 제어하기 위해 클래스 한정자를 사용해요
final, interface, sealed 같은 클래스 한정자는 클래스를 어떻게 확장할 수 있는지 제한해요. 예를 들어 final class A {}나 interface class B {}를 사용해 현재 라이브러리 밖에서의 확장을 막아 주세요. 문서에 의존하는 대신 이런 한정자를 사용해 의도를 전달해 주세요.
AVOID: 인터페이스로 의도되지 않은 클래스를 구현하지 마세요
암묵적 인터페이스는 Dart에서 강력한 도구예요. 구현 시그니처에서 계약을 사소하게 추론할 수 있을 때 클래스의 계약을 반복하지 않게 해주니까요.
하지만 클래스의 인터페이스를 구현하는 것은 그 클래스에 대한 매우 긴밀한 결합이에요. 거의 모든 클래스 변경이, 인터페이스를 구현하고 있는 여러분의 구현을 깨뜨릴 수 있다는 뜻이에요. 예를 들어 클래스에 새 멤버를 추가하는 것은 보통 안전하고 비파괴적인 변경이에요. 하지만 그 클래스의 인터페이스를 구현하고 있다면, 이제 여러분 클래스는 그 새 메서드의 구현이 없으므로 정적 오류를 가지게 돼요.
라이브러리 유지보수자는 사용자를 깨뜨리지 않고 기존 클래스를 발전시킬 능력이 필요해요. 모든 클래스를 사용자가 자유롭게 구현할 수 있는 인터페이스를 노출하는 것처럼 취급한다면, 그 클래스들을 바꾸는 것이 매우 어려워져요. 그 어려움은 결국 여러분이 의존하는 라이브러리가 성장하고 새 필요에 적응하는 속도를 느리게 해요.
사용하는 클래스의 작성자에게 더 많은 여유를 주려면, 명확히 구현되도록 의도된 클래스를 제외하고 암묵적 인터페이스 구현을 피하세요. 그렇지 않으면 작성자가 의도하지 않은 결합을 도입할 수 있고, 그들은 깨닫지 못한 채 여러분의 코드를 깨뜨릴 수 있어요.
DO: 클래스가 인터페이스가 될 수 있는지 제어하기 위해 클래스 한정자를 사용해요
라이브러리를 설계할 때 final, base, sealed 같은 클래스 한정자를 사용해 의도된 사용을 강제해 주세요. 예를 들어 final class C {}나 base class D {}를 사용해 현재 라이브러리 밖에서의 구현을 막아 주세요. 모든 라이브러리가 이런 한정자를 사용해 설계 의도를 강제하는 것이 이상적이지만, 개발자들은 여전히 적용되지 않은 경우를 만날 수 있어요. 그런 경우 의도하지 않은 구현 문제를 주의하세요.
PREFER: mixin 클래스보다 순수 mixin이나 순수 클래스를 정의해요
린터 규칙: prefer_mixin
Dart는 이전에(언어 버전 2.12에서 2.19까지) 특정 제한(비기본 생성자 없음, 상위 클래스 없음 등)을 충족하는 어떤 클래스든 다른 클래스에 mixin될 수 있게 했어요. 이는 클래스 작성자가 mixin될 것을 의도하지 않았을 수 있으므로 혼란스러웠어요.
Dart 3.0.0부터는 다른 클래스에 mixin되도록 의도된, 그리고 일반 클래스처럼 취급되도록 의도된 어떤 타입이든 mixin class 선언으로 명시적으로 선언해야 해요.
하지만 mixin과 클래스 둘 다여야 하는 타입은 드문 경우여야 해요. mixin class 선언은 주로 3.0.0 이전 클래스가 mixin으로 사용되던 것을 더 명시적인 선언으로 마이그레이션하는 데 도움을 주기 위한 것이에요. 새 코드는 순수 mixin이나 순수 클래스 선언만 사용해 선언의 동작과 의도를 명확히 정의하고, mixin 클래스의 모호성을 피해야 해요.
mixin 및 mixin class 선언에 대한 더 많은 안내는 클래스를 mixin으로 마이그레이션하기(Migrating classes as mixins)를 읽어 주세요.
생성자(Constructors)
Dart 생성자는 클래스와 같은 이름의 함수와, 선택적으로 추가 식별자를 선언해 만들어져요. 후자를 이름 붙은 생성자(named constructors)라고 불러요.
CONSIDER: 클래스가 지원한다면 생성자를 const로 만드는 것을 고려해요
모든 필드가 final이고 생성자가 그것들을 초기화하는 것 외에 아무것도 하지 않는 클래스가 있다면, 그 생성자를 const로 만들 수 있어요. 그러면 사용자가 상수가 필요한 곳, 즉 다른 더 큰 상수, switch case, 기본 매개변수 값 등을 안에서 클래스의 인스턴스를 만들 수 있어요.
명시적으로 const로 만들지 않으면 그렇게 할 수 없어요.
다만 const 생성자는 공개 API의 약속이라는 점에 유의해 주세요. 나중에 생성자를 비-const로 바꾸면, 상수 표현식에서 호출하는 사용자를 깨뜨려요. 그 약속을 원하지 않는다면 const로 만들지 마세요. 실제로 const 생성자는 단순하고 불변인 값류 타입에 가장 유용해요.
멤버(Members)
멤버는 객체에 속하며 메서드 또는 인스턴스 변수일 수 있어요.
PREFER: 필드와 최상위 변수를 final로 만드는 것을 선호해요
린터 규칙: prefer_final_fields
변하지 않는(시간이 지나도 바뀌지 않는) 상태는 프로그래머가 추론하기 더 쉬워요. 작업하는 가변 상태의 양을 최소화하는 클래스와 라이브러리는 유지보수하기 더 쉬운 경향이 있어요. 물론 가변 데이터가 유용한 경우가 많아요. 하지만 필요하지 않다면, 가능할 때 필드와 최상위 변수를 final로 만드는 것이 기본이어야 해요.
때로 인스턴스 필드는 초기화된 후에는 바뀌지 않지만, 인스턴스가 구성된 후에야 초기화될 수 있어요. 예를 들어 this나 인스턴스의 다른 필드를 참조해야 할 수 있어요. 그런 경우 필드를 late final로 만드는 것을 고려해 보세요. 그럴 때 선언 시점에 필드를 초기화할 수도 있어요.
DO: 개념적으로 프로퍼티에 접근하는 연산에는 게터를 사용해요
멤버가 게터여야 할지 메서드여야 할지 결정하는 것은 좋은 API 설계의 미묘하지만 중요한 부분이에요. 그래서 이 지침이 매우 긴 거예요. 어떤 다른 언어 문화는 게터를 꺼려해요. 연산이 거의 완전히 필드와 같을 때, 즉 객체에 전적으로 존재하는 상태에 대해 아주 작은 양의 계산을 할 때만 게터를 사용해요. 그것보다 더 복잡하거나 무거운 것은 이름 뒤에 ()를 붙여 "여기서 계산이 일어나고 있어요!"라는 신호를 보내요. . 뒤의 벌거벗은 이름은 "필드"를 뜻하니까요.
Dart는 그렇지 않아요. Dart에서 모든 점 이름(dotted name)은 계산을 수행할 수 있는 멤버 호출이에요. 필드는 특별해요. 언어가 구현을 제공하는 게터이니까요. 다시 말해, Dart에서 게터는 "특별히 느린 필드"가 아니라, 필드가 "특별히 빠른 게터"예요.
그래도 게터를 메서드보다 선택하는 것은 호출자에게 중요한 신호를 보내요. 그 신호는 대략 "연산이 필드처럼 생겼다"는 것이에요. 연산은 적어도 원칙적으로 호출자가 알기에는 필드를 사용해 구현될 수 있어요. 이것이 뜻하는 것은:
- 연산이 인자를 받지 않고 결과를 반환해요.
- 호출자가 결과에 대부분 신경 써요. 호출자가 만들어지는 결과보다 연산이 어떻게 그 결과를 만드는지 더 걱정하게 하고 싶다면, 그 작업을 설명하는 동사 이름을 주고 메서드로 만들어 주세요.
게터가 되기 위해 연산이 특히 빨라야 한다는 뜻은 아니에요. IterableBase.length는 O(n)이고 그것은 괜찮아요. 게터가 상당한 계산을 해도 괜찮아요. 하지만 놀라울 정도로 많은 작업을 한다면, 무엇을 하는지 설명하는 동사인 메서드로 만들어 그것에 주의를 끌고 싶을 수 있어요.
// 나쁨
connection.nextIncomingMessage; // Does network I/O.
expression.normalForm; // Could be exponential to calculate.
- 연산이 사용자에게 보이는 부수 효과를 갖지 않아요. 실제 필드에 접근하는 것은 객체나 프로그램의 다른 상태를 바꾸지 않아요. 출력을 만들지 않고, 파일을 쓰지 않아요. 게터도 그런 것을 하지 말아야 해요.
"사용자에게 보이는" 부분이 중요해요. 게터가 숨겨진 상태를 수정하거나 대역 밖의 부수 효과를 만드는 것은 괜찮아요. 게터는 지연 계산하고 결과를 저장하거나, 캐시에 쓰거나, 로그를 남길 수 있어요. 호출자가 부수 효과에 신경 쓰지 않는 한 아마 괜찮아요.
// 나쁨
stdout.newline; // Produces output.
list.clear; // Modifies object.
- 연산이 멱등(idempotent)이에요. "멱등"은 이상한 단어인데, 이 맥락에서는 기본적으로 그 호출 사이에 어떤 상태가 명시적으로 수정되지 않는 한 연산을 여러 번 호출하면 매번 같은 결과를 만들어낸다는 뜻이에요. (물론 그 호출 사이에 요소를 추가하면
list.length는 다른 결과를 만들어내요.)
여기서 "같은 결과"는 게터가 연속 호출에서 문자 그대로 동일한 객체를 만들어내야 한다는 뜻은 아니에요. 그렇게 요구하면 많은 게터가 취약한 캐싱을 강제하게 되어, 게터를 사용하는 의미 자체를 무산시켜요. 게터가 호출할 때마다 새 future나 list를 반환하는 것은 흔하고 완전히 괜찮아요. 중요한 것은 future가 같은 값으로 완료되고, list가 같은 요소를 담는다는 것이에요.
다시 말해 결과 값은 호출자가 신경 쓰는 측면에서 같아야 해요.
// 나쁨
DateTime.now; // New result each time.
- 결과 객체가 원본 객체의 전체 상태를 노출하지 않아요. 필드는 객체의 일부만 노출해요. 연산이 원본 객체의 전체 상태를 노출하는 결과를 반환한다면, 그것은
to___()나as___()메서드로 하는 것이 더 나을 가능성이 커요.
위의 모든 것이 연산을 설명한다면, 게터여야 해요. 그 가시밭길을 살아남는 멤버는 거의 없을 것 같지만, 놀랍게도 많은 것이 살아남아요. 많은 연산이 어떤 상태에 대해 약간의 계산을 수행할 뿐이고, 그중 대부분은 게터가 될 수 있고 되어야 해요.
// 좋음
rectangle.area;
collection.isEmpty;
button.canShow;
dataSet.minimumValue;
DO: 개념적으로 프로퍼티를 변경하는 연산에는 세터를 사용해요
린터 규칙: use_setters_to_change_properties
세터 사이에서 메서드 사이를 결정하는 것은 게터 사이에서 메서드 사이를 결정하는 것과 비슷해요. 두 경우 모두 연산이 "필드처럼" 보여야 해요.
세터에게 "필드처럼"은 다음을 뜻해요.
- 연산이 단일 인자를 받고 결과 값을 만들어내지 않아요.
- 연산이 객체의 어떤 상태를 변경해요.
- 연산이 멱등이에요. 같은 값으로 같은 세터를 두 번 호출하는 것은 호출자가 아는 한 두 번째에는 아무것도 하지 않아야 해요. 내부적으로 아마 캐시 무효화나 로깅이 진행될 수 있어요. 그것은 괜찮아요. 하지만 호출자 관점에서는 두 번째 호출이 아무것도 하지 않는 것처럼 보여요.
// 좋음
rectangle.width = 3;
button.visible = false;
DON'T: 대응하는 게터 없이 세터를 정의하지 마세요
린터 규칙: avoid_setters_without_getters
사용자는 게터와 세터를 객체의 보이는 프로퍼티로 생각해요. 쓸 수는 있지만 볼 수는 없는 "드롭박스" 프로퍼티는 혼란스럽고 프로퍼티가 어떻게 작동하는지에 대한 그들의 직관을 어긋나게 해요. 예를 들어 게터 없는 세터는 =로 수정할 수 있지만 +=로는 할 수 없다는 뜻이에요.
이 지침이 추가하려는 세터를 허용하기 위해 게터를 더해야 한다는 뜻은 아니에요. 객체는 일반적으로 필요한 것보다 더 많은 상태를 노출하지 말아야 해요. 객체 상태의 어떤 조각을 수정할 수 있지만 같은 방식으로 노출할 수 없다면, 대신 메서드를 사용해 주세요.
AVOID: 오버로딩을 흉내내기 위해 런타임 타입 검사를 사용하지 마세요
API가 다른 타입의 매개변수에 비슷한 연산을 지원하는 것은 흔해요. 그 유사성을 강조하기 위해 일부 언어는 오버로딩을 지원해요. 같은 이름이지만 다른 매개변수 목록을 가진 여러 메서드를 정의할 수 있게 해주는 것이에요. 컴파일 타임에 컴파일러는 실제 인자 타입을 보고 어떤 메서드를 호출할지 결정해요.
Dart에는 오버로딩이 없어요. 단일 메서드를 정의한 다음 본문 안에서 is 타입 검사를 사용해 인자의 런타임 타입을 보고 적절한 동작을 수행함으로써 오버로딩처럼 보이는 API를 정의할 수 있어요. 하지만 이렇게 오버로딩을 흉내 내면 컴파일 타임 메서드 선택이 런타임에 일어나는 선택으로 바뀌어요.
호출자가 보통 어떤 타입을 갖고 어떤 특정 연산을 원하는지 안다면, 호출자가 올바른 연산을 선택할 수 있도록 다른 이름의 별도 메서드를 정의하는 것이 더 좋아요. 이것은 더 나은 정적 타입 검사와 더 빠른 성능을 주는데, 어떤 런타임 타입 검사도 피하기 때문이에요.
하지만 사용자가 알려지지 않은 타입의 객체를 갖고 API가 내부적으로 is를 사용해 올바른 연산을 고르길 원한다면, 매개변수가 지원되는 모든 타입의 상위 타입인 단일 메서드가 타당할 수 있어요.
AVOID: 초기화가 없는 공개 late final 필드를 피하세요
다른 final 필드와 달리 초기화기가 없는 late final 필드는 세터를 정의해요. 그 필드가 공개라면 그 세터도 공개예요. 이것은 거의 원하는 것이 아니에요. 필드는 보통 인스턴스 수명 중 어떤 시점에, 종종 생성자 본문 안에서 내부적으로 초기화되도록 late로 표시돼요.
사용자가 세터를 호출하길 원하지 않는다면 다음 해결책 중 하나를 선택하는 것이 더 좋아요.
late를 사용하지 마세요.- factory 생성자를 사용해
final필드 값을 계산해 주세요. late를 사용하되late필드를 선언 시점에 초기화해 주세요.late를 사용하되late필드를 비공개로 만들고 그에 대한 공개 게터를 정의해 주세요.
AVOID: nullable Future, Stream, 컬렉션 타입을 반환하지 마세요
API가 컨테이너 타입을 반환할 때, 데이터가 없다는 것을 나타내는 두 가지 방법이 있어요. 빈 컨테이너를 반환하거나 null을 반환하는 것이에요. 사용자는 일반적으로 "데이터 없음"을 나타내는 데 빈 컨테이너를 사용한다고 가정하고 선호해요. 그러면 isEmpty 같은 메서드를 호출할 수 있는 실제 객체를 갖게 되니까요.
API가 제공할 데이터가 없다는 것을 나타내려면, 빈 컬렉션, nullable 타입의 non-nullable future, 또는 어떤 값도 방출하지 않는 stream을 반환하는 것을 선호해 주세요.
예외: null을 반환하는 것이 빈 컨테이너를 산출하는 것과 다르게 의미한다면, nullable 타입을 사용하는 것이 타당할 수 있어요.
AVOID: 플루언트 인터페이스를 위해 메서드에서 this를 반환하지 마세요
린터 규칙: avoid_returning_this
메서드 호출 체이닝에는 메서드 캐스케이드가 더 나은 해결책이에요.
// 좋음
var buffer = StringBuffer()
..write('one')
..write('two')
..write('three');
// 나쁨
var buffer = StringBuffer()
.write('one')
.write('two')
.write('three');
타입(Types)
프로그램에 타입을 적으면 코드의 여러 부분으로 흐르는 값의 종류를 제한해요. 타입은 두 종류의 위치에 나타날 수 있어요. 선언의 타입 애너테이션과 제네릭 호출의 타입 인자요.
타입 애너테이션은 "정적 타입"이라고 생각할 때 보통 떠올리는 것이에요. 변수, 매개변수, 필드, 반환 타입에 타입 애너테이션을 붙일 수 있어요. 다음 예제에서 bool과 String은 타입 애너테이션이에요. 그것들은 코드의 정적 선언 구조에 매달려 있고 런타임에 "실행"되지 않아요.
bool isEmpty(String parameter) {
bool result = parameter.isEmpty;
return result;
}
제네릭 호출은 컬렉션 리터럴, 제네릭 클래스 생성자에 대한 호출, 또는 제네릭 메서드의 호출이에요. 다음 예제에서 num과 int는 제네릭 호출의 타입 인자예요. 타입이지만, 그것들은 reify되어 런타임에 호출로 전달되는 일급 개체예요.
var lists = <num>[1, 2];
lists.addAll(List<num>.filled(3, 4));
lists.cast<int>();
여기서 "제네릭 호출" 부분을 강조해요. 타입 인자가 타입 애너테이션에도 나타날 수 있기 때문이에요.
List<int> ints = [1, 2];
여기서 int는 타입 인자지만, 제네릭 호출이 아닌 타입 애너테이션 안에 나타나요. 보통 이 구분을 걱정할 필요는 없지만, 타입이 타입 애너테이션 대신 제네릭 호출에서 사용될 때 다른 안내를 주는 몇몇 곳이 있어요.
타입 추론(Type inference)
타입 애너테이션은 Dart에서 선택 사항이에요. 생략하면 Dart는 주변 맥락을 기반으로 타입을 추론하려 해요. 때로 완전한 타입을 추론할 충분한 정보가 없어요. 그럴 때 Dart는 때로 오류를 보고하지만, 보통 조용히 빠진 부분을 dynamic으로 채워요. 암묵적 dynamic은 추론되고 안전해 보이지만 실제로는 타입 검사를 완전히 끄는 코드로 이어져요. 아래 규칙은 추론이 실패할 때 타입을 요구해 그것을 피해요.
Dart가 타입 추론과 dynamic 타입을 둘 다 가진다는 사실은 코드가 "untyped"라고 말하는 것이 무슨 뜻인지에 대해 약간의 혼란을 일으켜요. 그 코드가 동적 타입이라는 뜻인가, 아니면 타입을 쓰지 않았다는 뜻인가? 그 혼란을 피하기 위해 우리는 "untyped"라고 말하지 않고 다음 용어를 사용해요.
- 코드가 타입 애너테이션됨(type annotated) 이면, 타입이 코드에 명시적으로 작성되었어요.
- 코드가 추론됨(inferred) 이면, 타입 애너테이션이 쓰이지 않았고 Dart가 스스로 타입을 성공적으로 알아냈어요. 추론은 실패할 수 있으며, 그 경우 지침은 그것을 추론된 것으로 간주하지 않아요.
- 코드가 dynamic이면, 정적 타입이 특수
dynamic타입이에요. 코드는 명시적으로dynamic으로 애너테이션되거나 추론될 수 있어요.
다시 말해 어떤 코드가 애너테이션되었는지 추론되었는지는 그것이 dynamic인지 다른 타입인지와는 직교해요.
추론은 명백하거나 흥미롭지 않은 타입을 쓰고 읽는 수고를 아끼게 하는 강력한 도구예요. 독자의 주의를 코드 자체의 동작에 집중하게 해요. 명시적 타입도 견고하고 유지보수하기 쉬운 코드의 핵심 부분이에요. 그것들은 API의 정적 형태를 정의하고, 프로그램의 다른 부분에 도달할 수 있는 값의 종류를 문서화하고 강제하는 경계를 만들어요.
물론 추론은 마법이 아니에요. 때로 추론이 성공해 타입을 고르지만, 그것이 원하는 타입이 아닐 수 있어요. 흔한 경우는 나중에 다른 타입의 값을 변수에 할당하려고 했는데 변수의 초기화기에서 지나치게 정밀한 타입을 추론하는 것이에요. 그런 경우 타입을 명시적으로 써야 해요.
여기 지침들은 간결함과 제어, 유연성과 안전 사이에서 우리가 찾은 최상의 균형을 맞춰요. 모든 다양한 경우를 다루는 구체적인 지침이 있지만, 대략 요약하면 이래요.
- 추론이 충분한 맥락을 갖지 못할 때는, 그것이 원하는 타입이
dynamic이라도 애너테이션하세요. - 필요하지 않으면 지역 변수와 제네릭 호출에 애너테이션하지 마세요.
- 초기화기가 타입을 명백히 하지 않는 한 최상위 변수와 필드에 애너테이션하는 것을 선호하세요.
DO: 초기화가 없는 변수에는 타입 애너테이션을 붙여요
린터 규칙: prefer_typing_uninitialized_variables
변수(최상위, 지역, 정적 필드, 인스턴스 필드)의 타입은 종종 초기화기에서 추론될 수 있어요. 하지만 초기화기가 없으면 추론이 실패해요.
// 좋음
List<AstNode> parameters;
if (node is Constructor) {
parameters = node.signature;
} else if (node is Method) {
parameters = node.parameters;
}
// 나쁨
var parameters;
if (node is Constructor) {
parameters = node.signature;
} else if (node is Method) {
parameters = node.parameters;
}
DO: 타입이 명확하지 않으면 필드와 최상위 변수에 타입 애너테이션을 붙여요
린터 규칙: type_annotate_public_apis
타입 애너테이션은 라이브러리가 어떻게 사용되어야 하는지에 대한 중요한 문서예요. 그것들은 프로그램의 영역 사이에 경계를 형성해 타입 오류의 원인을 격리해요. 다음을 고려해 보세요.
// 나쁨
install(id, destination) => ...
여기서 id가 무엇인지 불분명해요. 문자열? 그리고 destination은 무엇인가요? 문자열 또는 File 객체? 이 메서드는 동기인가 비동기인가? 이것이 더 명확해요.
// 좋음
Future<bool> install(PackageId id, String destination) => ...
하지만 어떤 경우에는 타입이 너무 명백해서 그것을 쓰는 것이 무의미해요.
// 좋음
const screenWidth = 640; // Inferred as int.
"명백함"은 정밀하게 정의되지는 않지만, 다음은 모두 좋은 후보예요.
- 리터럴
- 생성자 호출
- 명시적으로 타입이 지정된 다른 상수에 대한 참조
- 숫자와 문자열에 대한 단순한 표현식
- 독자가 익숙하다고 기대되는
int.parse(),Future.wait()같은 factory 메서드
초기화기 표현식이 무엇이든 충분히 명확하다고 생각하면 애너테이션을 생략할 수 있어요. 하지만 애너테이션이 코드를 더 명확하게 만드는 데 도움이 된다고 생각하면 추가해 주세요.
의심스러우면 타입 애너테이션을 추가해 주세요. 명백한 타입을 명시적으로 애너테이션할 수도 있어요. 추론된 타입이 다른 라이브러리의 값이나 선언에 의존한다면, 다른 라이브러리의 변경이 알지 못한 채 여러분 API의 타입을 조용히 바꾸지 않도록 타입을 애너테이션하고 싶을 수도 있어요.
이 규칙은 공개와 비공개 선언 모두에 적용돼요. API의 타입 애너테이션이 코드 사용자에게 도움이 되는 것처럼, 비공개 멤버의 타입도 유지보수자에게 도움이 돼요.
DON'T: 초기화된 지역 변수에 타입 애너테이션을 중복해서 붙이지 마세요
린터 규칙: omit_local_variable_types
지역 변수는, 특히 함수가 작은 경향이 있는 현대 코드에서 범위가 매우 작아요. 타입을 생략하면 독자의 주의를 더 중요한 변수 이름과 초기화된 값에 집중하게 해요.
// 좋음
List<List<Ingredient>> possibleDesserts(Set<Ingredient> pantry) {
var desserts = <List<Ingredient>>[];
for (final recipe in cookbook) {
if (pantry.containsAll(recipe)) {
desserts.add(recipe);
}
}
return desserts;
}
// 나쁨
List<List<Ingredient>> possibleDesserts(Set<Ingredient> pantry) {
List<List<Ingredient>> desserts = <List<Ingredient>>[];
for (final List<Ingredient> recipe in cookbook) {
if (pantry.containsAll(recipe)) {
desserts.add(recipe);
}
}
return desserts;
}
때로 추론된 타입이 변수가 가지길 원하는 타입이 아닐 수 있어요. 예를 들어 나중에 다른 타입의 값을 할당하려고 할 수 있어요. 그 경우 원하는 타입으로 변수에 애너테이션해 주세요.
// 좋음
Widget build(BuildContext context) {
Widget result = Text('You won!');
if (applyPadding) {
result = Padding(padding: EdgeInsets.all(8.0), child: result);
}
return result;
}
DO: 함수 선언의 반환 타입에 애너테이션을 붙여요
Dart는 다른 일부 언어와 달리 함수 선언의 반환 타입을 본문에서 일반적으로 추론하지 않아요. 그것은 반환 타입에 대한 타입 애너테이션을 스스로 써야 한다는 뜻이에요.
// 좋음
String makeGreeting(String who) {
return 'Hello, $who!';
}
// 나쁨
makeGreeting(String who) {
return 'Hello, $who!';
}
이 지침은 비지역 함수 선언, 즉 최상위, 정적, 인스턴스 메서드와 게터에만 적용된다는 점에 유의해 주세요. 지역 함수와 익명 함수 표현식은 본문에서 반환 타입을 추론해요. 사실 익명 함수 구문은 반환 타입 애너테이션조차 허용하지 않아요.
DO: 함수 선언의 매개변수 타입에 애너테이션을 붙여요
함수의 매개변수 목록은 외부 세계에 대한 그것의 경계를 결정해요. 매개변수 타입에 애너테이션하면 그 경계가 잘 정의돼요. 기본 매개변수 값이 변수 초기화기처럼 보여도, Dart는 선택적 매개변수의 타입을 기본값에서 추론하지 않는다는 점에 유의해 주세요.
// 좋음
void sayRepeatedly(String message, {int count = 2}) {
for (var i = 0; i < count; i++) {
print(message);
}
}
// 나쁨
void sayRepeatedly(message, {count = 2}) {
for (var i = 0; i < count; i++) {
print(message);
}
}
예외: 함수 표현식과 초기화 형식 인자는, 다음 두 지침에서 설명하듯, 다른 타입 애너테이션 관례를 가져요.
DON'T: 함수 표현식의 추론된 매개변수 타입에 애너테이션을 붙이지 마세요
린터 규칙: avoid_types_on_closure_parameters
익명 함수는 거의 항상 어떤 타입의 콜백을 받는 메서드에 즉시 전달돼요. 함수 표현식이 타입화된 맥락에서 만들어지면, Dart는 기대 타입을 기반으로 함수의 매개변수 타입을 추론하려 해요. 예를 들어 함수 표현식을 Iterable.map()에 전달하면, 함수의 매개변수 타입은 map()이 기대하는 콜백 타입을 기반으로 추론돼요.
// 좋음
var names = people.map((person) => person.name);
// 나쁨
var names = people.map((Person person) => person.name);
언어가 함수 표현식에서 매개변수에 대해 원하는 타입을 추론할 수 있다면 애너테이션하지 마세요. 드물게 주변 맥락이 함수의 하나 이상의 매개변수에 타입을 제공할 만큼 정밀하지 않을 수 있어요. 그런 경우 애너테이션이 필요할 수 있어요. (함수가 즉시 사용되지 않는다면, 이름 붙은 선언으로 만드는 것이 보통 더 좋아요.)
DON'T: 초기화 형식 인자(initializing formals)에 타입 애너테이션을 붙이지 마세요
린터 규칙: type_init_formals
생성자 매개변수가 this.를 사용해 필드를 초기화하거나 super.를 사용해 super 매개변수를 전달한다면, 그 매개변수의 타입은 각각 필드나 super-생성자 매개변수와 같은 타입으로 추론돼요.
// 좋음
class Point {
double x, y;
Point(this.x, this.y);
}
class MyWidget extends StatelessWidget {
MyWidget({super.key});
}
// 나쁨
class Point {
double x, y;
Point(double this.x, double this.y);
}
class MyWidget extends StatelessWidget {
MyWidget({Key? super.key});
}
DO: 추론되지 않는 제네릭 호출에는 타입 인자를 작성해요
Dart는 제네릭 호출에서 타입 인자를 추론하는 데 꽤 영리해요. 표현식이 나타나는 기대 타입과 호출에 전달되는 값의 타입을 봐요. 하지만 때로 그것들이 타입 인자를 완전히 결정하기에 충분하지 않아요. 그런 경우 전체 타입 인자 목록을 명시적으로 작성해 주세요.
// 좋음
var playerScores = <String, int>{};
final events = StreamController<Event>();
// 나쁨
var playerScores = {};
final events = StreamController();
때로 호출이 변수 선언의 초기화기로 발생해요. 변수가 지역이 아니라면, 호출 자체에 타입 인자 목록을 쓰는 대신 선언에 타입 애너테이션을 넣을 수 있어요.
// 좋음
class Downloader {
final Completer<String> response = Completer();
}
// 나쁨
class Downloader {
final response = Completer();
}
변수에 애너테이션하는 것도 이 지침을 충족해요. 이제 타입 인자들이 추론되니까요.
DON'T: 추론되는 제네릭 호출에는 타입 인자를 작성하지 마세요
이것은 이전 규칙의 반대예요. 호출의 타입 인자 목록이 원하는 타입으로 올바르게 추론된다면, 타입을 생략하고 Dart가 일하도록 두세요.
// 좋음
class Downloader {
final Completer<String> response = Completer();
}
// 나쁨
class Downloader {
final Completer<String> response = Completer<String>();
}
여기서 필드의 타입 애너테이션은 초기화기의 생성자 호출의 타입 인자를 추론할 주변 맥락을 제공해요.
// 좋음
var items = Future.value([1, 2, 3]);
// 나쁨
var items = Future<List<int>>.value(<int>[1, 2, 3]);
여기서 컬렉션과 인스턴스의 타입은 요소와 인자에서 상향식으로 추론될 수 있어요.
AVOID: 불완전한 제네릭 타입을 작성하지 마세요
타입 애너테이션이나 타입 인자를 쓰는 목표는 완전한 타입을 고정하는 것이에요. 하지만 제네릭 타입의 이름을 쓰면서 타입 인자를 생략하면 타입을 완전히 지정하지 않은 것이에요. Java에서 이것을 "raw types"라고 불러요. 예를 들어:
// 나쁨
List numbers = [1, 2, 3];
var completer = Completer<Map>();
여기서 numbers에는 타입 애너테이션이 있지만, 그 애너테이션은 제네릭 List에 타입 인자를 제공하지 않아요. 마찬가지로 Completer에 대한 Map 타입 인자도 완전히 지정되지 않았어요. 이런 경우 Dart는 주변 맥락을 사용해 나머지 타입을 "채우려" 하지 않아요. 대신 빠진 타입 인자를 조용히 dynamic(또는 클래스에 바운드가 있으면 그 바운드)으로 채워요. 그것은 거의 원하는 것이 아니에요.
대신 타입 애너테이션이나 어떤 호출 안의 타입 인자로 제네릭 타입을 쓸 때 완전한 타입을 써 주세요.
// 좋음
List<num> numbers = [1, 2, 3];
var completer = Completer<Map<String, int>>();
DO: 추론이 실패하도록 두는 대신 dynamic으로 애너테이션을 붙여요
추론이 타입을 채우지 못하면 보통 dynamic으로 기본 설정돼요. dynamic이 원하는 타입이라면, 이것은 기술적으로 가장 간결한 방법이에요. 하지만 가장 명확한 방법은 아니에요. 코드의 캐주얼한 독자는 애너테이션이 빠진 것을 보고 그것이 dynamic을 의도했는지, 추론이 다른 타입을 채우기를 기대했는지, 아니면 그냥 애너테이션 쓰는 것을 잊었는지 알 방법이 없어요.
dynamic이 원하는 타입이라면, 의도를 명확히 하고 이 코드가 정적 안전성이 덜하다는 것을 강조하기 위해 명시적으로 써 주세요.
// 좋음
dynamic mergeJson(dynamic original, dynamic changes) => ...
// 나쁨
mergeJson(original, changes) => ...
Dart가 성공적으로 dynamic을 추론할 때 타입을 생략하는 것은 괜찮다는 점에 유의해 주세요.
// 좋음
Map<String, dynamic> readJson() => ...
void printUsers() {
var json = readJson();
var users = json['users'];
print(users);
}
여기서 Dart는 json에 대해 Map<String, dynamic>을 추론하고, 그다음 그것에서 users에 대해 dynamic을 추론해요. users를 타입 애너테이션 없이 두는 것은 괜찮아요. 구분은 조금 미묘해요. 다른 곳의 dynamic 타입 애너테이션에서 코드를 통해 dynamic이 퍼지도록 허용하는 것은 괜찮지만, 여러분 코드가 지정하지 않은 곳에 dynamic 타입 애너테이션을 주입하길 원하지는 않아요.
참고: Dart의 강한 타입 시스템과 타입 추론으로, 사용자들은 Dart가 추론된 정적 타입 언어처럼 동작하기를 기대해요. 그 정신 모델로는, 어떤 코드 영역이 정적 타입의 안전성과 성능을 조용히 모두 잃었다는 것을 발견하는 것이 불쾌한 놀라움이에요.
예외: 사용되지 않는 매개변수(_)의 타입 애너테이션은 생략할 수 있어요.
PREFER: 함수 타입 애너테이션에 시그니처를 사용해요
반환 타입이나 매개변수 시그니처 없이 홀로 쓰인 Function 식별자는 특수 Function 타입을 가리켜요. 이 타입은 dynamic을 사용하는 것보다 겨우 더 유용할 뿐이에요. 애너테이션할 거라면 함수의 매개변수와 반환 타입을 포함하는 전체 함수 타입을 선호해 주세요.
// 좋음
bool isValid(String value, bool Function(String) test) => ...
// 나쁨
bool isValid(String value, Function test) => ...
예외: 때로 여러 다른 함수 타입의 합집합을 나타내는 타입을 원할 수 있어요. 예를 들어 매개변수 하나를 받는 함수나 매개변수 둘을 받는 함수를 받아들일 수 있어요. 합집합 타입이 없으므로 그것을 정밀하게 타입화할 방법이 없고 보통 dynamic을 사용해야 해요. Function은 적어도 그보다 조금 더 도움이 돼요:
// 좋음
void handleError(void Function() operation, Function errorHandler) {
try {
operation();
} catch (err, stack) {
if (errorHandler is Function(Object)) {
errorHandler(err);
} else if (errorHandler is Function(Object, StackTrace)) {
errorHandler(err, stack);
} else {
throw ArgumentError('errorHandler has wrong signature.');
}
}
}
DON'T: 세터에 반환 타입을 지정하지 마세요
린터 규칙: avoid_return_types_on_setters
세터는 Dart에서 항상 void를 반환해요. 그것을 쓰는 것은 무의미해요.
// 나쁨
void set foo(Foo value) { ... }
// 좋음
set foo(Foo value) { ... }
[]= 연산자는 반환 타입에 대해 세터처럼 동작하므로 이 지침이 그것에도 적용돼요.
// 나쁨
void operator []=(K key, V value) { ... }
// 좋음
operator []=(K key, V value) { ... }
DON'T: 레거시 typedef 구문을 사용하지 마세요
린터 규칙: prefer_generic_function_type_aliases
Dart에는 함수 타입에 대한 이름 붙은 typedef를 정의하는 두 가지 표기가 있어요. 원래 구문은 다음과 같아요.
// 나쁨
typedef int Comparison<T>(T a, T b);
그 구문에는 몇 가지 문제가 있어요.
- 제네릭 함수 타입에 이름을 할당할 방법이 없어요. 위 예제에서 typedef 자체가 제네릭이에요. 코드에서 타입 인자 없이
Comparison을 참조하면 암묵적으로int Function(dynamic, dynamic)함수 타입을 얻어요. 정확히는int Function<T>(T, T)가 아니라요. 이것은 실제로 자주 발생하지는 않지만, 특정 모서리 경우에는 중요해요. - 매개변수에 있는 단일 식별자는 매개변수의 이름으로 해석되지, 타입으로 해석되지 않아요. 다음이 주어졌을 때:
대부분의 사용자는 이것이typedef bool TestNumber(num);num을 받고bool을 반환하는 함수 타입이기를 기대해요. 실제로는 어떤 객체(dynamic)를 받고bool을 반환하는 함수 타입이에요. 매개변수의 이름(typedef에서 문서 외에 아무것도 사용되지 않는)은 "num"이에요. 이것은 Dart에서 오랫동안 오류의 원인이었어요.
새 구문은 다음과 같아요.
// 좋음
typedef Comparison<T> = int Function(T, T);
매개변수 이름을 포함하고 싶다면 그렇게 할 수도 있어요.
// 좋음
typedef Comparison<T> = int Function(T a, T b);
새 구문은 옛 구문이 표현할 수 있는 모든 것을 그리고 그 이상을 표현할 수 있고, 단일 식별자가 타입 대신 매개변수 이름으로 취급되는 오류가 생기기 쉬운 잘못된 기능이 없어요. typedef의 = 뒤의 같은 함수 타입 구문은 타입 애너테이션이 나타날 수 있는 어디에나 허용되어, 프로그램 어디에서나 함수 타입을 쓰는 일관된 방법을 하나 제공해요.
옛 typedef 구문은 기존 코드를 깨뜨리지 않기 위해 여전히 지원되지만 deprecated예요.
PREFER: typedef보다 인라인 함수 타입을 선호해요
린터 규칙: avoid_private_typedef_functions
Dart에서 필드, 변수, 제네릭 타입 인자에 함수 타입을 사용하고 싶다면 그 함수 타입에 typedef를 정의할 수 있어요. 하지만 Dart는 타입 애너테이션이 허용되는 어디에서나 사용할 수 있는 인라인 함수 타입 구문을 지원해요.
// 좋음
class FilteredObservable {
final bool Function(Event) _predicate;
final List<void Function(Event)> _observers;
FilteredObservable(this._predicate, this._observers);
void Function(Event)? notify(Event event) {
if (!_predicate(event)) return null;
void Function(Event)? last;
for (final observer in _observers) {
observer(event);
last = observer;
}
return last;
}
}
함수 타입이 특히 길거나 자주 사용된다면 typedef를 정의할 가치가 여전히 있을 수 있어요. 하지만 대부분의 경우 사용자들은 함수 타입이 실제로 무엇인지 사용되는 바로 그 자리에서 보고 싶어하며, 함수 타입 구문은 그 명확성을 줘요.
PREFER: 매개변수에 함수 타입 구문을 사용해요
린터 규칙: use_function_type_syntax_for_parameters
Dart는 타입이 함수인 매개변수를 정의할 때 특수 구문이 있어요. C와 비슷하게, 매개변수 이름을 함수의 반환 타입과 매개변수 시그니처로 감싸요.
Iterable<T> where(bool predicate(T element)) => ...
Dart가 함수 타입 구문을 추가하기 전에는, typedef를 정의하지 않고 매개변수에 함수 타입을 주는 유일한 방법이 이것이었어요. 이제 Dart에 함수 타입의 일반 표기가 있으므로 함수 타입 매개변수에도 그것을 사용할 수 있어요.
// 좋음
Iterable<T> where(bool Function(T) predicate) => ...
새 구문은 조금 더 장황하지만, 새 구문을 사용해야 하는 다른 위치들과 일관돼요.
AVOID: 정적 검사를 끄고 싶지 않다면 dynamic을 사용하지 마세요
어떤 연산은 어떤 가능한 객체와도 작동해요. 예를 들어 log() 메서드는 어떤 객체든 받아 그것에 toString()을 호출할 수 있어요. Dart에서 모든 값을 허용하는 두 타입이 있어요. Object?와 dynamic이에요. 하지만 그것들은 다른 것을 전달해요. 모든 객체를 허용한다고 진술하고 싶다면 Object?를 사용해 주세요. null을 제외한 모든 객체를 허용하려면 Object를 사용해 주세요.
dynamic 타입은 모든 객체를 받아들일 뿐 아니라 모든 연산도 허용해요. dynamic 타입 값에 대한 어떤 멤버 접근이든 컴파일 타임에 허용되지만, 런타임에 실패해 예외를 throw할 수 있어요. 정확히 그 위험하지만 유연한 동적 디스패치를 원한다면 dynamic이 사용할 올바른 타입이에요.
그렇지 않으면 Object?나 Object를 사용하는 것을 선호해 주세요. 접근하기 전에 값의 런타임 타입이 접근하려는 멤버를 지원하는지 확인하기 위해 is 검사와 타입 승격에 의존해 주세요.
// 좋음
/// Returns a Boolean representation for [arg], which must
/// be a String or bool.
bool convertToBool(Object arg) {
if (arg is bool) return arg;
if (arg is String) return arg.toLowerCase() == 'true';
throw ArgumentError('Cannot convert $arg to a bool.');
}
이 규칙의 주요 예외는 특히 제네릭 타입 안에서 dynamic을 사용하는 기존 API를 다룰 때예요. 예를 들어 JSON 객체는 Map<String, dynamic> 타입이고 여러분 코드는 같은 타입을 받아야 해요. 그래도 이런 API 중 하나의 값을 사용할 때, 멤버에 접근하기 전에 더 정밀한 타입으로 캐스팅하는 것이 종종 좋은 생각이에요.
DO: 값을 생성하지 않는 비동기 멤버의 반환 타입으로 Future<void>를 사용해요
값을 반환하지 않는 동기 함수가 있을 때 void를 반환 타입으로 사용해요. 값을 만들지 않지만 호출자가 await할 필요가 있을 수 있는 메서드의 비동기 동등물은 Future<void>예요.
이전 Dart 버전은 void를 타입 인자로 허용하지 않았기 때문에 Future나 Future<Null>을 사용하는 코드를 볼 수 있어요. 이제 허용되므로 그것을 사용해야 해요. 이렇게 하면 비슷한 동기 함수에 타입을 지정하는 방식과 더 직접 일치하고, 호출자와 함수 본문에서 더 나은 오류 검사를 주어요.
유용한 값을 반환하지 않고 어떤 호출자도 비동기 작업을 await하거나 비동기 실패를 처리할 필요가 없는 비동기 함수에는 void 반환 타입을 사용해 주세요.
AVOID: 반환 타입으로 FutureOr<T>를 사용하지 마세요
메서드가 FutureOr<int>를 받는다면, 그것은 받는 것에 대해 너그러워요. 사용자는 int나 Future<int> 중 하나로 메서드를 호출할 수 있으므로, 어차피 풀게 될 Future에 int를 감쌀 필요가 없어요.
FutureOr<int>를 반환하면, 사용자는 유용한 무언가를 하기 전에 int를 받았는지 Future<int>를 받았는지 확인해야 해요. (아니면 값을 await해서 실제로 항상 Future처럼 취급할 거예요.) 그냥 Future<int>를 반환해 주세요. 함수가 항상 비동기이거나 항상 동기인지를 이해하는 것이 더 쉬워요. 둘 중 하나일 수 있는 함수는 올바르게 사용하기 어려워요.
// 좋음
Future<int> triple(FutureOr<int> value) async => (await value) * 3;
// 나쁨
FutureOr<int> triple(FutureOr<int> value) {
if (value is int) return value * 3;
return value.then((v) => v * 3);
}
이 지침의 더 정밀한 공식화는 FutureOr<T>를 반변(contravariant) 위치에서만 사용하라는 것이에요. 매개변수는 반변이고 반환 타입은 공변(covariant)이에요. 중첩 함수 타입에서는 이것이 뒤집혀요. 타입 자체가 함수인 매개변수가 있으면, 콜백의 반환 타입은 이제 반변 위치에 있고 콜백의 매개변수는 공변이에요. 이것은 콜백의 타입이 FutureOr<T>를 반환하는 것이 괜찮다는 뜻이에요.
// 좋음
Stream<S> asyncMap<T, S>(
Iterable<T> iterable,
FutureOr<S> Function(T) callback,
) async* {
for (final element in iterable) {
yield await callback(element);
}
}
매개변수(Parameters)
Dart에서 선택적 매개변수는 위치(positional) 또는 이름(named) 중 하나가 될 수 있지만, 둘 다는 될 수 없어요.
AVOID: 위치 기반 불리언 매개변수를 피하세요
린터 규칙: avoid_positional_boolean_parameters
다른 타입과 달리 불리언은 보통 리터럴 형태로 사용돼요. 숫자 같은 값은 보통 이름 붙은 상수로 감싸지만, 우리는 보통 true와 false를 직접 주고받아요. 그것은 불리언이 무엇을 나타내는지 명확하지 않으면 호출 지점을 읽기 어렵게 만들 수 있어요.
// 나쁨
new Task(true);
new Task(false);
new ListBox(false, true, true);
new Button(false);
대신 이름 붙은 인자, 이름 붙은 생성자, 또는 이름 붙은 상수를 사용해 호출이 무엇을 하는지 명확히 해 주세요.
// 좋음
Task.oneShot();
Task.repeating();
ListBox(scroll: true, showScrollbars: true);
Button(ButtonState.enabled);
이것은 세터에는 적용되지 않는다는 점에 유의해 주세요. 세터에서는 이름이 값이 무엇을 나타내는지 명확히 해주니까요.
// 좋음
listBox.canScroll = true;
button.isEnabled = false;
AVOID: 사용자가 앞선 매개변수를 생략하고 싶을 수 있다면 선택적 위치 매개변수를 피하세요
선택적 위치 매개변수는 앞선 매개변수가 뒤의 것보다 더 자주 전달되는 논리적 진행을 가져야 해요. 사용자는 뒤의 인자를 전달하기 위해 앞선 위치 인자를 생략하는 "구멍"을 명시적으로 전달해야 하는 경우가 거의 없어야 해요. 그런 용도에는 이름 붙은 인자를 사용하는 것이 더 나아요.
// 좋음
String.fromCharCodes(Iterable<int> charCodes, [int start = 0, int? end]);
DateTime(
int year, [
int month = 1,
int day = 1,
int hour = 0,
int minute = 0,
int second = 0,
int millisecond = 0,
int microsecond = 0,
]);
Duration({
int days = 0,
int hours = 0,
int minutes = 0,
int seconds = 0,
int milliseconds = 0,
int microseconds = 0,
});
AVOID: 특수한 "인자 없음" 값을 받는 필수 매개변수를 피하세요
사용자가 논리적으로 매개변수를 생략하는 경우라면, null, 빈 문자열, 또는 "전달되지 않음"을 뜻하는 다른 특수 값을 전달하도록 강제하는 대신 매개변수를 선택적으로 만들어 실제로 생략하게 해 주세요.
매개변수를 생략하는 것은 더 간결하고, 사용자가 실제 값을 제공한다고 생각할 때 실수로 null 같은 센티널 값을 전달하는 버그를 막는 데 도움이 돼요.
// 좋음
var rest = string.substring(start);
// 나쁨
var rest = string.substring(start, null);
DO: 범위를 받을 때 시작 포함(inclusive)과 끝 제외(exclusive) 매개변수를 사용해요
정수 인덱스가 매겨진 시퀀스에서 요소나 항목의 범위를 사용자가 선택하게 하는 메서드나 함수를 정의한다면, 첫 항목을 가리키는 시작 인덱스와 마지막 항목의 인덱스보다 하나 큰 (대개 선택적인) 끝 인덱스를 받아 주세요.
이것은 같은 것을 하는 핵심 라이브러리들과 일관돼요.
// 좋음
[0, 1, 2, 3].sublist(1, 3) // [1, 2]
'abcd'.substring(1, 3) // 'bc'
이것들은 매개변수가 보통 이름 없이 사용되기 때문에 여기서 일관성이 특히 중요해요. API가 끝점 대신 길이를 받는다면, 그 차이는 호출 지점에서 전혀 보이지 않아요.
동등(Equality)
클래스에 대해 사용자 지정 동등 동작을 구현하는 것은 까다로울 수 있어요. 사용자는 동등이 어떻게 작동하는지에 대한 깊은 직관을 갖고 있으며 여러분 객체는 그것과 일치해야 해요. 해시 테이블 같은 컬렉션 타입은 요소가 따라야 할 미묘한 계약을 기대해요.
DO: ==를 오버라이드하면 hashCode도 오버라이드해요
린터 규칙: hash_and_equals
기본 해시 코드 구현은 식별 해시(identity hash)를 제공해요. 두 객체는 일반적으로 정확히 같은 객체일 때만 같은 해시 코드를 가져요. 마찬가지로 ==의 기본 동작도 식별(identity)이에요.
==를 오버라이드한다면, 여러분 클래스가 "동등"하다고 간주하는 서로 다른 객체가 있을 수 있다는 뜻이에요. 동등한 두 객체는 같은 해시 코드를 가져야 해요. 그렇지 않으면 Map과 다른 해시 기반 컬렉션은 두 객체가 동등하다는 것을 인식하지 못해요.
DO: == 연산자가 수학적 동등 규칙을 따르게 해요
동치 관계(equivalence relation)는 다음이어야 해요.
- 반사적(Reflexive):
a == a는 항상true를 반환해야 해요. - 대칭적(Symmetric):
a == b는b == a와 같은 것을 반환해야 해요. - 추이적(Transitive):
a == b와b == c가 둘 다true를 반환하면a == c도 그래야 해요.
==를 사용하는 사용자와 코드는 이 모든 법칙이 지켜지길 기대해요. 여러분 클래스가 이 규칙을 지킬 수 없다면, ==는 표현하려는 연산에 올바른 이름이 아니에요.
AVOID: 가변 클래스에 사용자 지정 동등을 정의하지 마세요
린터 규칙: avoid_equals_and_hash_code_on_mutable_classes
==를 정의할 때는 hashCode도 정의해야 해요. 둘 다 객체의 필드를 고려해야 해요. 그 필드가 변한다면 객체의 해시 코드가 바뀔 수 있다는 뜻이에요.
대부분의 해시 기반 컬렉션은 그것을 예상하지 않아요. 객체의 해시 코드가 영원히 같을 것이라고 가정하고, 그것이 사실이 아니면 예측할 수 없게 동작할 수 있어요.
DON'T: ==의 매개변수를 nullable로 만들지 마세요
린터 규칙: avoid_null_checks_in_equality_operators
언어는 null이 오직 자기 자신과만 동등하고, == 메서드는 오른쪽이 null이 아닐 때만 호출된다고 지정해요.
// 좋음
class Person {
final String name;
// ···
bool operator ==(Object other) => other is Person && name == other.name;
}
// 나쁨
class Person {
final String name;
// ···
bool operator ==(Object? other) =>
other != null && other is Person && name == other.name;
}