Scala 完整实践:ADT校验分类账与JVM排错
项目合同
前置:起步、建模、JVM资源。项目examples/scala/ledger不需要sbt、Spark或第三方服务。读取本地无BOM UTF-8文本,行格式name|amount;name首字符字母,随后字母/数字/_/-,总长1–32;amount是1–999,无符号、空格或前导零。最多10000行,空文件成功、空行错误,支持LF/CRLF、末尾无换行。
输出按ASCII名称排序的name=total,全部成功后一次输出;坏参数、文件读取或格式错误均退出2并写stderr,stdout无半成品。最多9990000保证Int容纳总额,但这依赖明确的行数/金额限制;扩大规模时需重新审计溢出,不因Scala是高级语言就忽略。读取以行为单位,超长单行可能先分配内存再拒绝,本项目不是对任意恶意文件的内存上限证明。
完整源码与测试
Ledger.scala
import java.nio.charset.StandardCharsets
import java.nio.file.{Files, Path}
import scala.util.{Using, Try}
final case class Entry(name: String, amount: Int)
enum Problem:
case Invalid(line: Int)
case TooMany
object Ledger:
private val Row = raw"([A-Za-z][A-Za-z0-9_-]{0,31})\|([1-9][0-9]{0,2})".r
def parse(line: String, number: Int): Either[Problem, Entry] = line match
case Row(name, amount) => Right(Entry(name, amount.toInt))
case _ => Left(Problem.Invalid(number))
def summarize(lines: Iterator[String]): Either[Problem, String] =
val totals = scala.collection.mutable.Map.empty[String, Int]
var number = 0
var problem: Option[Problem] = None
while problem.isEmpty && lines.hasNext do
number += 1
if number > 10000 then problem = Some(Problem.TooMany)
else parse(lines.next(), number) match
case Left(error) => problem = Some(error)
case Right(entry) => totals.update(entry.name, totals.getOrElse(entry.name, 0) + entry.amount)
problem match
case Some(error) => Left(error)
case None => Right(totals.toVector.sortBy(_._1).map((name, amount) => s"$name=$amount\n").mkString)
def main(args: Array[String]): Unit =
val result: Try[Either[Problem, String]] = Try {
require(args.length == 1, "usage: Ledger FILE")
Using.resource(Files.newBufferedReader(Path.of(args(0)), StandardCharsets.UTF_8)) { reader =>
val lines = Iterator.continually(reader.readLine()).takeWhile(_ != null)
summarize(lines)
}
}
result.fold(
error => { System.err.println(error.getMessage); sys.exit(2) },
_.fold(
error => { System.err.println(error.toString); sys.exit(2) },
output => print(output)
)
)Tests.scala
object Tests:
def main(args: Array[String]): Unit =
assert(Ledger.summarize(Iterator("z|2", "a|3", "z|4")) == Right("a=3\nz=6\n"))
List("a|0", "a|01", "a|2|3", "a| 2", "a|1000", "x!|1", "").foreach { line =>
assert(Ledger.parse(line, 1).isLeft)
}
var read = 0
val source = Iterator("a|0", "a|2").map { line => read += 1; line }
assert(Ledger.summarize(source).isLeft)
assert(read == 1)
assert(Ledger.summarize(Iterator.empty) == Right(""))
println("Scala module: ADT validation, totals, first-error termination passed")Entry是乘积数据,Problem是带行号或数量错误的和类型,Either将错误保留在返回值里。while只在problem为空时读取下一行;局部mutable Map不泄漏给调用者,最终转换为排序文本。正则提取器在模式匹配中要求整个字符串匹配,不用不受控的toInt处理任意外部输入。main的Try处理I/O异常;Using保证Reader关闭;返回类型组合是Try[Either[Problem,String]],不能把异常和业务校验错误混成一个空字符串。
测试显式assert,总额、非法记录、空输入和首错读取次数均检查;Scala assert不会像Java原生assert那样默认关闭。编译时不要启用会消除检查的选项;进程测试另检查退出码与stdout/stderr。
编译、运行、失败复现
先建立out,保存sample.txt三行z|2、a|3、z|4,无BOM UTF-8。在工程目录:
scalac -d out Ledger.scala Tests.scala Lab.scalaWindows运行 java -cp "out;D:/tools/scala/lib/scala.jar" Tests 和相同类路径下的Ledger sample.txt;Linux使用冒号分隔类路径。完整路径按自己解压位置修改,不能把分号用于Linux。也可用可联网的Scala CLI运行整个目录并指定--main-class Tests,但课程实际证据采用离线scalac/java链路。
Tests输出Scala module: ADT validation, totals, first-error termination passed。Ledger输出a=3、z=6;把最后一行改z|0,必须stderr显示Invalid(3)、退出2且stdout为空。文件不存在、多传参数、非法编码也要失败;正则业务错误与MalformedInputException是两种不同来源。验证驱动verify.py每门17种真实进程场景,不使用输出字符串冒充运行。
类型错误、类路径、字节码与诊断
Not found常由导入、作用域或漏编译源文件引起;No given instance先看using能力和given导入;MatchError可能是未穷尽匹配或运行期值突破静态假设。ClassNotFoundException检查-cp和包名,NoClassDefFoundError检查Scala运行库是否加入,UnsupportedClassVersionError检查编译/运行JDK兼容性。不要为类路径问题重写业务算法。
javap -classpath out Ledger可查看公开入口;需要具体实现时看生成的Ledger$。java -Xlog:gc -cp ... Lab显示真实GC信息;JFR命令见JVM实验,实际能解析的录制才算采集成功。对集合热点先看是否反复List索引、重复物化或保留LazyList头部,再决定换结构;不要一见GC就给更大堆。
在Ledger.parse处打断点,检查number和line;在Using内部返回处单步,确认输出构造在资源关闭前完成。性能Lab使用Long平方避免Int乘法先溢出;把n.toLong移到乘法之后会先产生错误Int再转换,这不是“更快同算法”。所有优化先保证等价结果。
综合练习与参考解法
练习:增加Amount opaque type,使非法金额不能绕过校验构造;为parse失败保留行号和原因;将首错停止改成累计最多20条错误,说明新的资源/复杂度契约。为什么Iterator不可重复sum,Scala Future超时也不停止原计算?
参考方向
在Amount作用域隐藏底层Int,公开返回Either的工厂与只读访问/求和操作;不能只声明opaque别名就声称完成验证。错误累计需要遍历后续行,增加错误集合上限与停止条件,测试第20/21个错误、有效行混杂和超限文件;结果必须明确全失败或部分接受,不能悄悄变语义。Iterator消费游标,Future没有通用cancel,均有明确的协议边界。
本批覆盖Scala3语言、类型建模、集合/错误、资源、受控JVM中断和完整项目。Spark、分布式Actor、Cats/ZIO、sbt大型构建与宏编译器专题不作为本批已完成内容。依据与实际版本见前页及工作包verification.md。
继续阅读
- 类型、表达式与建模
- 类型、表达式与建模
- 类型、表达式与建模