mcpField
D
dart3-idioms-and-coding-standards

dart3-idioms-and-coding-standards

github.com/zakariaf开发63158 次安装5 周前更新
代码仓库

关于 dart3-idioms-and-coding-standards

规范 Dart 3 类型设计、不可变性、命名和全函数的编码技能,面向希望利用编译器检查穷尽分支并统一代码结构的开发者。

主要功能

  1. 01密封类型使用穷尽式 switch
  2. 02类修饰符与枚举的使用规范
  3. 03不可变值类型与稳定身份标识
  4. 04全函数及非抛出领域逻辑规范
  5. 05Dart 命名与复杂度限制
  6. 06Dart 3 惯用法检查脚本

适用场景

  1. 01建模封闭的领域结果集合
  2. 02设计跨应用层的不可变值
  3. 03检查 switch 是否遗漏变体
  4. 04提交 PR 前检查 Dart 代码

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/zakariaf/flutter-skills --skill dart3-idioms-and-coding-standards

安装后在 Claude Code 中输入/dart3-idioms-and-coding-standards确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

examples/
immutable_value.dart
sealed_outcome.dart
total_domain_function.dart
references/
complexity-and-honesty.md
construct-verdict-table.md
immutability-and-equality.md
scripts/
check-dart3-idioms.sh

详细介绍

Dart 3 惯用法和编码规范

现代 Dart 3.x,优先使用值类型,领域逻辑采用全函数。只有当某项语言特性能将运行时的静默问题转化为编译错误时,它才有存在的价值;其他一切都只是装饰。本规范约束每个声明的类型标注、命名、不可变性和全函数性——建立在这些机制之上的错误处理架构由 error-handling-typed-results 负责。

请根据当前任务查阅相应参考资料:

  • references/construct-verdict-table.md — 逐项列出功能的判定表(sealed / enum / record / class 修饰符 / extension type / 代码生成包),并说明每项选择使用 / 跳过的理由。
  • references/immutability-and-equality.md — 不可变值类型、copyWith、何时手写 ==/hashCode,以及稳定标识与值相等。
  • references/complexity-and-honesty.md — 长度和嵌套层级限制及其依据,以及完整列出的 late/!/dynamic 规避规则。

提交 PR 前运行 scripts/check-dart3-idioms.sh。

不容妥协的规则

  1. 对 sealed 类型或枚举使用 switch 时,不要写 default: 或 case _:。 通配分支会让 switch 永远都能编译,丢掉类型存在的唯一编译时保证——添加变体后,运行时会静默落入该分支。穷尽性就是全部价值所在。
  2. 只使用三种类修饰符,其余一概忽略。 对需要编译器穷尽检查的封闭变体集合使用 sealed class;对所有具体叶子类使用 final class;对由测试替身实现的扩展接口使用 abstract interface class。具体类型默认设为 final。不要使用 base、extension type、主构造函数或宏。
  3. 将每个情况都可单独处理的封闭集合建模为 sealed 层级;不带载荷的封闭集合建模为 enum。 从 enum 开始;一旦任何成员需要字段,就改为 sealed + final class。不要为了只适用于部分成员的数据而给 enum 添加可空字段——这会让每次访问都需要空值检查,而编译器无法推理其安全性。
  4. 记录永远不能跨越层边界。 记录是没有名称、按位置访问且没有文档说明的结构——适合在单个层内临时返回多个值。一旦某个结构从仓库返回、被存储或传给 widget 构造函数,就应使用具名类。
  5. 领域值不可变:使用 final 字段、在允许时使用 const 构造函数、值相等性,以及通过 copyWith 派生新值。 修改交给 widget 的值,会破坏重建和 golden test。只要分析器允许,就优先使用 final 局部变量和 const 构造函数。
  6. 身份必须是明确且稳定的字段,绝不能按所有字段判等。 用作列表元素或映射键的值类型应包含 final String id(或类型化 ID)。从所有字段派生身份,会把值恰好相同的两个不同实体(例如两个都名为 “Draft” 的 Item)合并成一个。
  7. 让非法状态无法表示。 将带判别条件的选择编码为 sealed 变体或以 enum 为键的联合类型,而不是编码为只有一个字段会被设置的可空字段集合。若类型无法表达错误状态,就不需要任何分支来防范它。
  8. 领域函数必须是全函数——绝不抛出异常。 每个纯函数都应为每个输入返回一个值;不确定性应作为明确的输出表示(例如经过钳制的值、“N 步内无结果”的结果或低置信度标记)。程序员不变量使用 assert(发布版中会移除),绝不使用 throw。可恢复的 I/O 故障应返回类型化结果——参见 error-handling-typed-results。
  9. 不要掩饰问题:不要用 late 规避空值处理,不要对重要值使用 !,不要把 dynamic/Map<String, dynamic> 当作临时模型。 这些做法都会隐藏类型系统原本会迫使你处理的运行时故障。空值使用 ?./??/类型提升;结构化数据使用类型化模型。
  10. 原样遵循 Effective Dart 的命名规则;常量使用 lowerCamelCase。 类型/扩展/枚举使用 UpperCamelCase;文件/目录/导入前缀使用 lowercase_with_underscores;变量/参数/方法/常量使用 lowerCamelCase(maxItems,绝不使用 MAX_ITEMS);超过两个字母的缩写按单词形式大写(JsonMap、HttpClient,而非 JSONMap)。文件名应与其主要声明一致。
  11. 将复杂度限制严格作为默认标准——本表是本库唯一的标准来源;其他技能应引用本表,而非重述数字: 方法 ≤ 约 30 行,build() ≤ 约 80 行,文件 ≤ 约 300 行,类的公共 API ≤ 约 10 个成员,位置参数 ≤ 3 个,逻辑嵌套 ≤ 3 层。widget 构建树合理地会有更深的嵌套——widget 构建嵌套 ≤ 5 层是唯一明确的例外(由 widget-composition 引用)。这些是促使重构的提示,不是硬性规定——若某处超限但保持内聚是合理的(把一个状态机拆散到五个片段中反而更糟),可在 PR 中说明理由。
  12. 优先使用不可变值类型;简单类型手写,样板代码占主导时使用 freezed。 对简单的不可变类型(1–3 个字段),手写 @immutable + const 构造函数 + final 字段(用作映射键时再手动实现 == / Object.hash)。对于 copyWith/==/hashCode/sealed 联合类型的样板代码变得繁琐的领域值类型或 UI 状态值类型,允许使用 freezed,且这是默认选择——*.freezed.dart 是一等生成产物。不要使用 equatable(五行 == + Object.hash 就足够),不要使用 fpdart/dartz(Either 会抹去穷尽性),也不要使用任何 --enable-experiment 标志(标志一旦被移除,停止维护的仓库从那天起就无法构建)。

密封类:支撑性惯用模式

将一组封闭且可单独处理的情形建模为一个文件中的密封层次结构。sealed 要求每个子类型都位于同一个库中——该文件就是这组封闭情形,编译器会强制执行这一点。

@immutable
sealed class SyncOutcome {
  const SyncOutcome();
}

final class SyncApplied extends SyncOutcome {
  const SyncApplied(this.appliedCount);
  final int appliedCount;
}

final class SyncSkipped extends SyncOutcome {
  const SyncSkipped(this.reason);
  final String reason;
}

使用不带 default: 的 switch——这样,添加变体后,在每个位置处理该变体之前都会出现编译错误:

// RIGHT — exhaustive, no wildcard.
switch (outcome) {
  case SyncApplied(:final appliedCount):
    log.info('applied $appliedCount');
  case SyncSkipped(:final reason):
    log.warning('skipped: $reason');
}

如果遗漏了一个分支,dart analyze 会报告 non_exhaustive_switch_statement,而且 dart compile 也会失败。绝不要抑制此错误;被抑制的分析器诊断仍会导致 AOT 构建失败,因此 CI 必须执行构建,而不能只做分析。

类修饰符:优先考虑三种

意图声明
编译器必须穷尽处理的一组封闭变体sealed class(隐式抽象;无法在其库外实例化或实现)
具体的叶子类——变体、服务实现、值类型final class
测试替身必须满足的接缝abstract interface class

默认将每个具体类型声明为 final class:它会同时禁止扩展和实现,因此不会有子类在不知情的情况下继承一半行为,也不会有测试意外地通过 implements 一个具体类来获取其字段。abstract interface class 表示“实现我,不要扩展我”——这正是测试替身所需的契约;单独使用 abstract class 会允许 extends,而服务接口上的继承默认实现,正是测试替身悄悄调用真实实现的原因。跳过 base(它强制执行单包应用并不具备的库边界不变量)和 extension type(一种明确标记为不安全的抽象——其底层表示仍然可访问)。

Record:适用场景与代价

在单个层中,用 record 返回临时的多个值:(int row, int col) 作为局部坐标非常理想——免费获得结构相等性,无需类,也无需代码生成。当一个 record 不再只是几行代码中的临时值时,为字段命名:({int row, int col}) 在使用处一目了然;而 (int, int) 若把顺序写反,编译器无法发现这一错误。

// Intra-layer only. Two same-typed positional fields = a latent swap bug.
({int page, int size}) nextPage(({int page, int size}) current) =>
    (page: current.page + 1, size: current.size);

不要为了绕过类的选择而用 typedef 定义 record 形状——在多个文件中使用的具名形状应当使用类。

Enum 还是 sealed

当集合是封闭的,且每个成员都不带载荷时,先用 enum——例如显示类别或模式。Enum 在 switch 中同样具备编译器穷尽性检查,而且每个成员只需一行代码。只要有成员需要字段,就应改用 sealed + final class。

enum OrderStatus { draft, submitted, shipped, cancelled }

// A member needs data -> sealed, not an enum with parallel nullable fields.
@immutable
sealed class Payment {
  const Payment();
}
final class Cash extends Payment { const Cash(); }
final class Card extends Payment {
  const Card(this.last4);
  final String last4;
}

Switch 表达式与 if-case

当每个分支都会产生一个值,且没有分支包含语句体时,优先使用 switch 表达式——它在构造上就是穷尽的,也不会发生贯穿执行:

final label = switch (status) {
  OrderStatus.draft => 'Draft',
  OrderStatus.submitted => 'Submitted',
  OrderStatus.shipped => 'Shipped',
  OrderStatus.cancelled => 'Cancelled',
};

当各分支会执行副作用时,使用 switch 语句;不要为了写成表达式而把副作用塞进 void 接收器。对于只有一种值得关注的形状,而 switch 会变成一个有效分支加一个无用分支的情况,使用 if (x case Pattern)——但绝不要对 sealed 类型使用 if-case,否则会悄悄重新引入 switch 本可捕获的非穷尽漏洞。

if (settings.retry case final RetryPolicy p when p.isEnabled) {
  await scheduler.arm(p);
}

不可变值类型与标识

手写四行代码即可替代代码生成器。只有当类型会用作映射键或集合成员时,才手写 ==/hashCode;对于列表中的身份识别,应使用稳定的 id。

@immutable
final class Task {
  const Task({required this.id, required this.title, required this.done});

  final String id; // identity — NOT derived from title/done
  final String title;
  final bool done;

  Task copyWith({String? title, bool? done}) =>
      Task(id: id, title: title ?? this.title, done: done ?? this.done);

  @override
  bool operator ==(Object other) =>
      other is Task && other.id == id && other.title == title && other.done == done;

  @override
  int get hashCode => Object.hash(id, title, done);
}

全定义域函数

纯函数会为每个输入返回结果。不确定性应表示为值;程序员不变量则应使用 assert。

/// Total: clamps rather than throwing on an out-of-range page.
int clampPage(int requested, int pageCount) {
  assert(pageCount >= 1, 'pageCount must be positive'); // programmer invariant
  if (requested < 0) return 0;
  if (requested >= pageCount) return pageCount - 1;
  return requested;
}

反模式

  • 在密封类型或枚举上使用 default: 或 case _: —— 这会丢弃穷尽性检查;新增变体时会在运行时落入该分支,而不是编译失败。
  • 在密封类型上使用 if (x case SomeVariant()) —— 这会重新引入 switch 能捕获的非穷尽性漏洞。
  • 使用带构造函数和可空字段的 enum,且这些字段只适用于部分成员 —— 每次访问都需要进行编译器无法推理的空值检查;改用 sealed + final class。
  • 将从仓库返回的记录存储起来,或通过小部件构造函数传递 —— 把无名的位置形状当作领域类型;应改用类。
  • 将 class Order extends ChangeNotifier 用作可变领域模型 —— 领域值不可变;应通过 ViewModel 修改(参见 state-management-riverpod)。
  • 根据所有字段的 == 推导身份 —— 两个值相同的实体会被合并为一个;应使用稳定的 id。
  • 用 late Foo foo; / foo!.value 规避空值,或将 Map<String, dynamic> 用作模型 —— 这会掩盖运行时故障;应使用 ?./??/类型提升和有类型的模型。
  • 从纯领域函数中 throw 来表示正常结果 —— 应返回结果;assert 仅用于程序员不变量。
  • 使用 const MAX_ITEMS / class JSONModel / final s = 3; —— 使用了 SCREAMING_CAPS、大小写不当的缩写或简写;应使用 maxItems、JsonModel 和完整单词。
  • 使用 Widget _buildHeader() => ... —— 它会随父级一起重建,也不能是 const;应提取为 const 小部件类(参见 widget-composition)。
  • 添加 equatable/fpdart/dartz 或 --enable-experiment 标志 —— 这会增加冗余负担,或使用抹去穷尽性的 Either;手动编写这几行代码,或在样板代码确实过多时使用 freezed。

完成标准

  • 对密封类型或枚举的每个 switch 都是穷尽的,且没有 default:/case _:。
  • 具体类型使用 final class;接口边界使用 abstract interface class;封闭集合使用 sealed/enum;不使用 base/extension type/实验标志。
  • 封闭的可操作集合使用 sealed + final class;不带负载的集合使用 enum;不使用带可空字段的标记枚举。
  • 记录仅在层内使用;所有跨边界的数据形状都是具名类。
  • 领域值不可变(字段为 final,在允许时使用 const 构造函数,提供 copyWith);身份使用稳定的 id,而不是所有字段相等。
  • 非法状态无法表示(使用 sealed/enum 联合,而非可空字段集合)。
  • 领域函数是全函数——返回不确定性,不抛出异常;不变量使用 assert。
  • 对重要值不使用 late/!/dynamic/Map<String,dynamic> 来掩盖问题。
  • 原样遵循 Effective Dart 大小写规范;常量使用 lowerCamelCase;文件名与其主要声明一致。
  • 遵守复杂度限制;超出限制时,须在 PR 中说明其合理性。
  • scripts/check-dart3-idioms.sh 通过;dart analyze 无问题。

相关技能

  • error-handling-typed-results —— Result/Failure 主干和全局错误网,它们依赖这些密封类型机制。
  • naming-conventions —— 使用角色后缀命名(Screen/Notifier/Repository/Service/Failure),以标明所属层。
  • state-management-riverpod —— 可变状态的存放位置(基于不可变状态的 Notifier ViewModel);此处的领域值保持不可变。
  • widget-composition —— 提取 const 小部件类,而不是 _buildX() 方法。
  • dart3-idioms-and-coding-standards 由 lint-and-style-config 和 ci-pipeline-and-gates 在 CI 中强制执行。

参考资料

评论

支持 Markdown