Dart 完整实践:流式分类账与失败测试
文件合同与输出
前置:基础与建模、Future与Stream。这是独立Dart CLI,标准库即可完成。输入为无BOM UTF-8,无表头;每行name|amount,name是1–32个ASCII字母/数字/下划线/短横线、首字符字母;金额1–999且无前导零、空格或符号。最多10000行,空文件成功但空行失败;支持LF/CRLF及末行无换行。输入不是任意CSV,不处理引号转义或多行字段。
输出分类按String排序,每行name=total;ASCII名称使跨平台排序一致。成功0;格式/读取/参数错误2,stderr说明原因、stdout无部分报告。所有整数在当前上限下不超过9990000;CLI运行于Dart VM,不把本轮验证写成JavaScript后端测试。输入逐行解码但一个超长行仍可先占内存,应在外层根据部署需求限制文件大小与字节行长。
完整源码与错误测试
examples/dart/ledger下仅需以下文件,不需要pubspec或第三方包;test.dart是使用明确check与throw的测试程序,避免只依赖可被关闭的语言assert。
ledger.dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';
class Entry {
final String name;
final int amount;
const Entry(this.name, this.amount);
}
final _row = RegExp(r'^([A-Za-z][A-Za-z0-9_-]{0,31})\|([1-9][0-9]{0,2})$');
Entry parse(String line, int number) {
final match = _row.firstMatch(line);
if (match == null || match.end != line.length) {
throw FormatException('line $number: expected name|1..999');
}
return Entry(match[1]!, int.parse(match[2]!));
}
Future<String> summarize(Stream<String> lines) async {
final totals = <String, int>{};
var number = 0;
await for (final line in lines) {
if (++number > 10000) throw const FormatException('more than 10000 rows');
final entry = parse(line, number);
totals.update(entry.name, (value) => value + entry.amount, ifAbsent: () => entry.amount);
}
final names = totals.keys.toList()..sort();
return names.map((name) => '$name=${totals[name]}\n').join();
}
Future<void> main(List<String> args) async {
try {
if (args.length != 1) throw ArgumentError('usage: dart run ledger.dart FILE');
final lines = File(args.single).openRead().transform(utf8.decoder).transform(const LineSplitter());
stdout.write(await summarize(lines));
} on Object catch (error) {
stderr.writeln(error);
exitCode = 2;
}
}test.dart
import 'dart:async';
import 'ledger.dart';
void check(bool value) { if (!value) throw StateError('assertion failed'); }
Future<void> main() async {
check(await summarize(Stream.fromIterable(['z|2', 'a|3', 'z|4'])) == 'a=3\nz=6\n');
for (final line in ['a|0', 'a|01', 'a|2|3', 'a| 2', 'a|1000', 'x!|1', '']) {
try { parse(line, 1); throw StateError('accepted invalid'); } on FormatException { /* expected */ }
}
var cancelled = false;
final controller = StreamController<String>(onCancel: () { cancelled = true; });
final work = summarize(controller.stream);
controller.add('a|0');
try { await work; throw StateError('accepted invalid stream'); } on FormatException { check(cancelled); }
await controller.close();
var finished = false;
final original = Future<void>.delayed(const Duration(milliseconds: 30), () { finished = true; });
try { await original.timeout(Duration.zero); throw StateError('timeout missing'); } on TimeoutException { }
await original;
check(finished); // timeout does not cancel original Future
print('Dart module: parse, totals, stream cancellation, Future timeout passed');
}parse构造不可变字段的Entry;RegExp匹配保证分组存在后,两个!都有局部证明;未校验的外部String不应随意加!。totals只在当前异步函数内更新,不跨isolate共享。map.update处理已存在与首次记录,最后复制键列表后排序,避免在遍历映射时改变结构。库如果收到坏行会抛FormatException并退出await-for;上游订阅取消由测试的onCancel明确检查。
运行与正常/失败验证
保存sample.txt三行z|2、a|3、z|4,采用无BOM UTF-8。Windows PowerShell7可用Set-Content -Encoding utf8NoBOM sample.txt @('z|2','a|3','z|4');Linux Bash可用printf 'z|2\na|3\nz|4\n' > sample.txt。在工程目录:
dart analyze ledger.dart test.dart lab.dart
dart run test.dart
dart run ledger.dart sample.txt
dart run lab.dartCLI预期a=3、z=6;测试预期Dart module: parse, totals, stream cancellation, Future timeout passed。故意把最后一行改为z|0,退出2且没有前两行的部分汇总。Windows检查
分析器、堆栈与性能
A value of type ... can't be assigned先看var推断、?和泛型;不要把所有变量改dynamic绕过分析器。LateInitializationError是延迟字段先读,Null check operator used on a null value是!的不变量被打破;两者不是同一种“SDK问题”。FormatException可能来自业务parse,也可能来自UTF-8解码,应结合堆栈和消息判断。相对路径相对启动目录,不能假定相对ledger.dart。
在编辑器给parse入口与catch设断点,查看行号、原始line及match;异常断点可在抛出时停住,避免只看到最终CLI错误。运行性能实验保持相同结果,观察迭代与物化差别;Dart VM首次运行有JIT预热,AOT编译可用dart compile exe ledger.dart -o ledger,它验证不同部署产物,不能拿一次JIT时间当AOT性能。
Flutter资料的去向与边界
旧Dart基础中的安装、final集合可变、回调和对象例子已经融入本课程。Flutter资料属于框架层:SDK自带Dart、doctor检查目标依赖、create/devices/run流程仍有参考价值,保留第一个Flutter应用作为补充,并明确它的设备运行未在本语言CLI批次验证。Widget生命周期、跨页状态、导航和平台构建不属于本项目的完成声明。
旧HelloWorld里的Android Build Tools28.0.3与旧IDE菜单是特定旧工程问题,不能作为当前安装步骤;去Flutter检查章节核对实际错误。启动锁先确认其他Flutter/IDE进程仍在工作,不能照旧笔记递归删除SDK锁。国内包源依赖当前网络和政策,按官方镜像说明检查,不在正文固定个人路径或无依据切源。
综合复习
练习:增加最大类别数限制并在超过时首错退出,写StreamController测试确认取消;证明timeout没有停止Future;解释final Map和不可变Entry的区别;在输入有效前不发stdout的设计解决什么问题?
参考方向
只有首次出现新name时检查不同键数,边界包含上限与上限+1;测试等待summarize的错误,再验证onCancel。保留原Future并等待完成后检查副作用,不把超时当取消。final仅冻结Map变量绑定,Entry的final字段限制字段重赋;二者都不意味着递归冻结任意对象。结果先汇总后输出防止坏输入产生一半看似成功的报告,但不提供文件系统事务。
依据:dart analyze、File.openRead、Flutter安装排查。核对2026-10-17。
继续阅读
- 知识专题
- 知识专题
- 开发实践