Skip to content

第十章练习答案 ​

先独立完成每篇练习,再比较解释。答案重点是结论成立的理由;运行环境与前提也是答案的一部分。

执行上下文 ​

1.调用者的变量不会替换闭包环境。 triple 创建时关联的是 makeScale(3) 那次调用的 factor。可以让同一外层环境同时提供读取和修改函数:

js
function createScale(initial) {
  let factor = initial
  return {
    scale(value) { return factor * value },
    change(next) { factor = next }
  }
}
const scale = createScale(3)
console.log(scale.scale(7)) // 21
scale.change(4)
console.log(scale.scale(7)) // 28

两方法共享同一个绑定,因此修改有效。不是调用者名字相同就会产生共享。

2.浏览器普通脚本与模块。 普通脚本顶层 var 声明通常对应全局对象属性,顶层 let 不会;普通脚本顶层 this 指向全局 this 值。模块的声明保持模块作用域,顶层 this 是 undefined。用两个分别含 <script> 与 <script type="module"> 的文件验证,避免控制台特殊处理影响判断。Node CommonJS 还要单独考虑,不能把浏览器结论直接套过去。

3.跨 Realm 数组。 外部数组的原型来自另一套内置对象,未必包含当前的 Array.prototype。Array.isArray 用于判断数组身份,之后仍需验证元素结构。

4.异步函数的错误进入其 Promise。 调用者同步 try 已结束,恢复后的异常不能退回那条同步调用链。改为 try { await inspectLater() } catch (error) { /* 处理 */ },或者在返回的 Promise 上注册拒绝处理。即使异步函数在第一个 await 之前抛错,普通调用接收的也是被拒绝的 Promise。

引擎与测量 ​

1.核对文章的版本、源码提交和规范约束。 长度阈值与算法属于对应实现的历史细节。现代排序稳定性由规范约束,不能沿用旧源码推断所有环境。

2.不同规模对应不同成本比例。 很小数据可能主要反映计时开销,大数据还受到缓存与垃圾回收影响。重复运行观察分布,保留总和以确认计算结果确实被使用且各版本计算同一任务。仍需避免声称仅靠总和就完全排除了优化器影响。

3.优化是假设,不是更改语义的授权。 引擎会检查假设或建立失效机制;假设失效后回到能处理新情况的路径。返回字符串、调用 getter 等合法行为必须保留。

4.副作用与恢复位置都必须保留。 两次 getter 调用可能修改状态或返回不同值,不能任意合并。优化失败后必须从语义上正确的进度继续,不能重复先前已发生的写入、日志或外部调用。实际恢复方式属于对应引擎实现。

5.先用可重复输入证明两种实现不等价。

js
import assert from 'node:assert/strict'
const failure = new Error('第二次读取失败')
function input() {
  let reads = 0
  return {
    get price() {
      if (++reads === 2) throw failure
      return 10
    }
  }
}
function original(item) { return item.price + item.price }
function cached(item) {
  const price = item.price
  return price + price
}
assert.throws(() => original(input()), error => error === failure)
assert.equal(cached(input()), 20)
function repaired(item) { return item.price + item.price }
assert.equal(repaired({ price: 10 }), 20)
assert.throws(() => repaired(input()), error => error === failure)
console.log('普通值与异常路径均通过')

每次调用 input 都从第一次读取开始,错误对象保持同一身份以便检查传播。修复必须保留第二次读取;缓存版本即使更快,也是在计算不同的行为。只有修改接口契约、明确排除变化访问器等输入,才有理由另行考虑单次读取。

现代协议 ​

1.用 take 截断,再收集。

js
function* integers() { let n = 1; while (true) yield n++ }
if (typeof Iterator !== 'undefined') {
  console.log(Iterator.from(integers()).map(n => n * n).take(4).toArray())
  // [1, 4, 9, 16]
}

map 不会限制数量,toArray 想读到结束;take(4) 给无限来源建立了终止条件。提前结束时还可能触发底层迭代器的清理。

2.分组没有深复制。 分组数组存放原对象引用,修改同一对象会被原数组观察到;分类键需要对象身份时使用 Map.groupBy。需要独立数据时应明确复制范围。

3.对象属性存在与语法可解析是两回事。 兼容库可以提供 Symbol.dispose,不能让旧解析器认识 using。需要在支持该语法的独立环境运行,或采用明确的转译方案;传统 try/finally 可以直接表达释放责任。

4.输出上限不等于搜索上限。 如果 filter 永远不通过,take 无法获得三项,消费无限源就不能结束。对于真正异步协议的输入,Array.fromAsync 等待 next 结果,不默认再等待结果中的 value;提供映射函数时会等待映射结果。同步输入适配及异步生成器本身又有自己的等待步骤,应逐层判断。

5.成员判断决定取舍,遍历来源决定顺序。

js
import assert from 'node:assert/strict'
function reconcile(available, selected) {
  const valid = new Set()
  const invalid = new Set()
  for (const id of selected) {
    if (available.has(id)) valid.add(id)
    else invalid.add(id)
  }
  return { valid, invalid }
}
const available = new Set(['b', 'a'])
const selected = new Set(['a', 'b', 'c'])
const result = reconcile(available, selected)
assert.deepEqual([...result.valid], ['a', 'b'])
assert.deepEqual([...result.invalid], ['c'])
assert.deepEqual([...available], ['b', 'a'])
assert.deepEqual([...selected], ['a', 'b', 'c'])
assert.equal(reconcile(new Set(), new Set()).valid.size, 0)
assert.equal(reconcile(new Set([NaN]), new Set([NaN])).valid.has(NaN), true)
console.log('成员、顺序与输入保持不变均通过')

循环始终按 selected 的插入顺序处理,两个结果都是新集合。这里输入契约限定为普通 Set;has 对 NaN 的判断仍使用 Set 的相等规则。intersection 可能从较小的一方遍历,不能在 UI 必须保持选择顺序时依赖偶然相同的输出。

6.失败前登记的资源仍由当前持有者负责。 以下独立程序只在支持 DisposableStack 的环境进入实验:

js
if (typeof DisposableStack !== 'undefined') {
  function initialize(log, fail) {
    const pending = new DisposableStack()
    try {
      pending.adopt({ close() { log.push('关闭资源') } }, item => item.close())
      pending.defer(() => log.push('解除订阅'))
      if (fail) throw new Error('初始化失败')
      return pending.move()
    } finally {
      pending.dispose()
    }
  }
  const failed = []
  try { initialize(failed, true) } catch (error) { failed.push(error.message) }
  console.log(failed) // ['解除订阅', '关闭资源', '初始化失败']
  const success = []
  const owner = initialize(success, false)
  try {
    console.log(success.length) // 0
  } finally {
    owner.dispose()
  }
  owner.dispose()
  console.log(success) // ['解除订阅', '关闭资源']
}

失败发生在 move 之前,pending 仍拥有两个动作;成功路径则转交给 owner。finally 的存在同时保护正常与失败路径。若用前文传统嵌套 finally 模拟失败,预期同样是内层清理、外层清理、外部捕获,但那个实验不能检验 move 或 disposed 等新 API 行为。

时间模型 ​

1.根据含义建模。 生日可保存日历日期或月日;日志保存瞬间;周期预约保存当地时间、时区和重复规则。未来某日的偏移可能变化,因此只保存本次 UTC 偏移不够表达“纽约每周一九点”。

2.日历推进与固定时长不同。 夏令时调整时,两个相邻当地日期同一钟表时间之间可能不是24小时。固定有效期与日历规则应分开处理。

3.兼容性属于运行与交付选择。 选择支持环境、合适兼容实现或保留明确的旧能力路径。typeof 可以防止调用不存在的对象,却不能自动完成缺失功能;仅输出“暂不支持”也不等于业务已经实现。

4.约束日期会丢失原日号信息。 闰年1月31日加一个月约束到2月29日,再减一个月是1月29日。应保存“月末”这样的日程规则。回拨的当地时间可用 Temporal.ZonedDateTime.from(fields, { disambiguation: "reject" }) 拒绝,或明确选择 earlier/later 候选;代码需要支持Temporal的环境。

5.始终从月初锚点计算偏移。 下面的代码需要支持 Temporal:

js
if (typeof Temporal !== 'undefined') {
  function monthEnds(start, count) {
    if (!Number.isInteger(count) || count < 0 || count > 24) {
      throw new RangeError('count 必须是 0~24 的整数')
    }
    const anchor = Temporal.PlainDate.from(start).with({ day: 1 })
    if (anchor.calendarId !== 'iso8601') throw new RangeError('只接受 ISO 日历')
    return Array.from({ length: count }, (_, offset) => {
      const month = anchor.add({ months: offset })
      return month.with({ day: month.daysInMonth }).toString()
    })
  }
  console.log(monthEnds('2024-01-31', 3))
  // ['2024-01-31', '2024-02-29', '2024-03-31']
  console.log(monthEnds('2023-12-15', 3))
  // ['2023-12-31', '2024-01-31', '2024-02-29']
  console.log(monthEnds('2024-01-31', 0)) // []
  try { monthEnds('2024-01-31', 1.5) } catch (error) {
    console.log(error.name) // RangeError
  }
}

每次都从同一个月初出发,而不是从上一项已约束过的月末出发,2 月的短日号不会污染 3 月的计算。count 为 0 仍会验证 start 是可接受日期;如果接口希望空请求完全不读取 start,应另行明确契约。此任务限定 ISO 日历,其他历法应单独设计和验证,不能凭此例覆盖全部历法。

规范与旧代码 ​

1.异常是转换操作的结果,必须传播。 随意吞掉转换错误再字符串化会改变可观察行为,也可能掩盖用户代码问题。

2.Function 构造器不捕获创建位置的局部环境。 普通函数闭包可以明确表达:

js
function createLabel(text) {
  return function read() { return text }
}
console.log(createLabel('局部资料')()) // 局部资料

3.旧教学分类反映当时语境。 模块有自己的环境记录和加载求值过程;生成器、异步函数还有暂停恢复行为。现代规范的执行上下文是描述工具,不能把旧文中的三分法或“全局栈帧永远在底部”当成覆盖所有作业阶段的实现规则。

4.分组括号保留引用来源,逗号表达式取出函数值。 前者的属性调用可使用panel作为接收者;后者在模块严格模式中普通调用的this为undefined。finally正常完成才保留try先前的完成方式,finally抛错则以新错误结束。

5.未得到原始值、得到 undefined、发生异常是三条路径。

js
import assert from 'node:assert/strict'
const failure = new Error('转换中断')
function sample(mode) {
  const log = []
  const value = {
    valueOf() {
      log.push('valueOf')
      if (mode === 'throw') throw failure
      return mode === 'object' ? {} : undefined
    },
    toString() { log.push('toString'); return '15' }
  }
  return { value, log }
}
const fallback = sample('object')
assert.equal(Number(fallback.value), 15)
assert.deepEqual(fallback.log, ['valueOf', 'toString'])
const primitive = sample('undefined')
assert.equal(Number.isNaN(Number(primitive.value)), true)
assert.deepEqual(primitive.log, ['valueOf'])
const rejected = sample('throw')
assert.throws(() => Number(rejected.value), error => error === failure)
assert.deepEqual(rejected.log, ['valueOf'])
const custom = sample('object')
custom.value[Symbol.toPrimitive] = () => ({})
assert.throws(() => Number(custom.value), TypeError)
assert.deepEqual(custom.log, [])
console.log('四条转换路径均通过')

自定义钩子返回对象时直接失败,普通 valueOf 返回对象才继续到 toString。返回 undefined 已完成原始值转换,接下来才产生 NaN;抛错则传播同一个错误对象。按路径检查日志,比只看最后“是不是数字”更能定位规则。

旧项目维护 ​

1. 私有状态与独立重置 ​

保存为 reader.test.mjs,运行 node reader.test.mjs。断言失败会抛错,成功时输出提示。

js
import assert from 'node:assert/strict'
function createReader(start) {
  if (!Number.isInteger(start) || start < 0 || start > 1000) throw new RangeError('起点非法');
  var count = start;
  return {
    next: function () { return ++count; },
    current: function () { return count; },
    reset: function () { count = start; return count; }
  };
}
const a = createReader(2), b = createReader(10)
assert.equal(a.next(), 3)
a.count = 999
assert.equal(a.current(), 3)
assert.equal(a.reset(), 2)
assert.equal(b.current(), 10)
for (const input of [-1, 1001, 1.5, '2', NaN]) assert.throws(() => createReader(input), RangeError)
console.log('独立状态与重置通过')

start 与 count 都在每次工厂调用的作用域里;reset 改的是内部 count,没有对外部属性作约定。校验发生在创建时,后续 next 增长不受起点上界限制。

2. 原型错误与修复的同一组观察 ​

js
import assert from 'node:assert/strict'
function Folder() { this.items = [] }
Folder.prototype.add = function (x) { this.items.push(x) }
function Bad() {}
Bad.prototype = new Folder()
const badA = new Bad(), badB = new Bad()
badA.add('共享错误')
assert.deepEqual(badB.items, ['共享错误'])
function Good() { Folder.call(this) }
Good.prototype = Object.create(Folder.prototype)
Object.defineProperty(Good.prototype, 'constructor', {
  value: Good, writable: true, configurable: true
})
const a = new Good(), b = new Good()
a.add('独立')
assert.deepEqual(a.items, ['独立'])
assert.deepEqual(b.items, [])
assert.equal(a.add, b.add)
assert.equal(a instanceof Folder, true)
console.log('状态独立、方法共享通过')

修复没有复制 add,而是让每个实例初始化自己的 items。只修 constructor 不会改变数组所在位置。

3. 保留工厂行为再改变模块格式 ​

三个文件放在同一目录,先运行 node factory.test.cjs:

js
// factory.cjs
function createCounter(start) {
  var count = start;
  return {
    next: function () { return ++count; },
    read: function () { return count; }
  };
}
module.exports = createCounter;
js
// factory.test.cjs
var assert = require('node:assert/strict');
var create = require('./factory.cjs');
assert.equal(create, require('./factory.cjs'));
var a = create(2), b = create(10);
assert.equal(a.next(), 3);
assert.equal(a.read(), 3);
assert.equal(b.read(), 10);
console.log('CommonJS 工厂通过');
js
// factory.mjs
export default function createCounter(start) {
  let count = start
  return { next: () => ++count, read: () => count }
}

将测试保存为另一个 factory.test.mjs,替换 require 为 import 后,仍检查相同的外部行为:

js
// factory.test.mjs
import assert from 'node:assert/strict'
import create from './factory.mjs'
import again from './factory.mjs'
assert.equal(create, again)
const a = create(2), b = create(10)
assert.equal(a.next(), 3)
assert.equal(a.read(), 3)
assert.equal(b.read(), 10)
console.log('ESM 工厂通过')

两边共享导出的工厂函数,不共享每次调用建立的 count。这里的方法不使用动态 this,因此箭头函数没有改变题目契约;不能将这个结论推广到所有旧方法。

当前标准能力 ​

以下测试独立保存为 .mjs,使用正文指定的 Node 26.0.0 执行。

1. 缓存区分缺失与空值 ​

js
import assert from 'node:assert/strict'
function getBucket(map, key) {
  return map.getOrInsertComputed(key, () => [])
}
const map = new Map([['empty', undefined]])
assert.equal(getBucket(map, 'empty'), undefined)
const a = getBucket(map, 'a')
a.push(1)
assert.equal(getBucket(map, 'a'), a)
assert.deepEqual(map.get('a'), [1])
assert.deepEqual(getBucket(map, 'b'), [])
assert.notEqual(map.get('b'), a)
console.log('键存在与数组身份通过')

创建函数写在回调里,只有缺失的键得到新数组。已有 undefined 不需要也不允许被替换。

2. 从原文恢复大整数 ​

js
import assert from 'node:assert/strict'
function parseIds(text) {
  return JSON.parse(text, (key, value, context) => {
    if (key !== 'id') return value
    if (typeof value !== 'number' || !/^-?\d+$/.test(context.source)) {
      throw new TypeError('id 必须是十进制整数字面量')
    }
    return BigInt(context.source)
  })
}
const value = parseIds('{"id":9007199254740993,"child":{"id":-2},"name":"笔记"}')
assert.equal(value.id, 9007199254740993n)
assert.equal(value.child.id, -2n)
assert.equal(value.name, '笔记')
for (const id of ['"3"', '1.5', '1e3']) {
  assert.throws(() => parseIds(`{"id":${id}}`), TypeError)
}
assert.equal(parseIds('{"id":0}').id, 0n)
console.log('JSON 原文与契约通过')

如果改为 BigInt(value),大整数断言会失败。仅判断 Number.isInteger 也不够:指数表示可得到整数,但不满足本题的字面量契约。

3. 消费与关闭顺序 ​

js
import assert from 'node:assert/strict'
function experiment(early) {
  const log = []
  function* source(name, values) {
    log.push(`打开${name}`)
    try { yield* values }
    finally { log.push(`关闭${name}`) }
  }
  const cursor = Iterator.concat(source('甲', [1, 2]), source('乙', [3]))
  assert.deepEqual(log, [])
  if (early) { assert.equal(cursor.next().value, 1); cursor.return() }
  else assert.deepEqual(cursor.toArray(), [1, 2, 3])
  return log
}
assert.deepEqual(experiment(true), ['打开甲', '关闭甲'])
assert.deepEqual(experiment(false), ['打开甲', '关闭甲', '打开乙', '关闭乙'])
console.log('拼接来源生命周期通过')

提前关闭只处理已经开始的来源。若只比较最终数组,就无法证明没有提前打开乙。

4. 字节与文本分别往返 ​

js
import assert from 'node:assert/strict'
for (const text of ['笔记', '']) {
  const bytes = new TextEncoder().encode(text)
  const decoded = Uint8Array.fromBase64(bytes.toBase64())
  assert.deepEqual(decoded, bytes)
  assert.equal(new TextDecoder('utf-8', { fatal: true }).decode(decoded), text)
}
assert.throws(() => Uint8Array.fromHex('0'), SyntaxError)
assert.throws(() => Uint8Array.fromHex('gg'), SyntaxError)
console.log('字节与文本往返通过')

字节相同证明 Base64 往返,字符串相同再证明使用了正确的字符编码;两层观察不应混为一次猜测。

5. 精确求和的作用与限制 ​

在正文已安装 math.sumprecise@1.0.1 的实验目录运行:

js
import assert from 'node:assert/strict'
import sumPrecise from 'math.sumprecise/implementation'
for (const values of [[1e16, 1, -1e16], [-1e16, 1e16, 1], [1, -1e16, 1e16]]) {
  assert.equal(sumPrecise(values), 1)
}
assert.equal(Object.is(sumPrecise([]), -0), true)
assert.throws(() => sumPrecise([1, '2']), TypeError)
assert.equal(sumPrecise([0.1, 0.2]), 0.30000000000000004)
console.log('兼容实现:求和与边界通过')

这里显式调用兼容实现。若换成普通 reduce,中间舍入的排列测试会失败;若把它误解为十进制运算,最后的期望值又会错误。精确累加已经表示的输入和恢复输入原本想表达的十进制数,是两个不同问题。

用心记录每一步成长