《Eloquent JavaScript》 · 07-项目-机器人
《Eloquent JavaScript》 · 07-项目-机器人
[2026-08-23 17:03:18 星期日] -> [2026-08-23 18:31:32 星期日] 整理完第 7 章。在线阅读:《Eloquent JavaScript》 · 07-项目:机器人
“机器能否思考的问题…… 就像问潜艇是否会游泳一样毫无意义。”
——埃兹格尔·迪克斯特拉,《计算科学的威胁》
前六章学了语言基础——值、控制流、函数、数据结构、高阶函数、对象。这一章停理论、做程序:搭一个邮递机器人,把前面学的串起来跑一个完整项目。项目章节不灌新概念,重在阅读和理解真实程序。
一、引子:项目章节
在"项目"章节中,我会暂时停止向您灌输新的理论,而是和您一起完成一个程序。理论对于学习编程来说是必要的,但阅读和理解实际程序同样重要。
本章项目是构建一个自动机——一个小程序,在虚拟世界里执行一项任务。我们的自动机是一个邮递机器人,负责收取和投递包裹。
二、草地村:道路图
草地村 11 个地点、14 条道路,用道路数组描述:
const roads = [
"Alice's House-Bob's House", "Alice's House-Cabin",
"Alice's House-Post Office", "Bob's House-Town Hall",
"Daria's House-Ernie's House", "Daria's House-Town Hall",
"Ernie's House-Grete's House", "Grete's House-Farm",
"Grete's House-Shop", "Marketplace-Farm",
"Marketplace-Post Office", "Marketplace-Shop",
"Marketplace-Town Hall", "Shop-Town Hall"
];

道路网络形成一个图——点的集合(村庄中的地点)以及它们之间的线(道路)。这个图就是机器人移动的世界。
字符串数组不太容易使用,我们感兴趣的是"从给定地点能到哪些地方"。把道路列表转成"每个地点 → 可达地点数组"的映射:
让我们将道路列表转换为一个数据结构,它会为每个地点告诉我们从那里可以到达哪些地方。【严谨的勒:那 -> 哪】
function buildGraph(edges) {
let graph = Object.create(null); // 无原型裸对象,安全做映射(第 6 章学过)
function addEdge(from, to) {
if (from in graph) {
graph[from].push(to);
} else {
graph[from] = [to];
}
}
for (let [from, to] of edges.map(r => r.split("-"))) { // "A-B".split("-") → [A,B],解构
addEdge(from, to);
addEdge(to, from); // 道路双向
}
return graph;
}
/*
给定一个边数组,buildGraph 创建一个映射对象,它为每个节点存储一个连接节点的数组。它使用 split 方法将道路字符串(形式为 "Start-End")转换为包含起点和终点作为单独字符串的两个元素数组。
*/
const roadGraph = buildGraph(roads);
补充:
buildGraph用Object.create(null)做无原型映射(避免第 6 章的toString坑);split("-")把"A-B"拆成两点,解构[from, to]取出——第 4、5 章的解构用到这里了。
三、任务与状态:VillageState
机器人在村庄四处移动,各处有包裹寄往别处,机器人经过就拾取、到目的地就投递。自动机每步决定去哪,包裹全投完即完成任务。
为了能够模拟这个过程,得定义描述虚拟世界的模型——告诉我们机器人在哪、包裹在哪。当机器人决定移动到某个地方时,我们需要更新模型以反映新的情况。
如果从面向对象角度思考,你的第一直觉可能是给世界中的各种元素定义对象:一个用于机器人的类、一个用于包裹的类、一个用于地点的类。然后,它们可以包含描述其当前状态的属性,例如某个位置的包裹堆,当我们更新世界时,我们可以改变它。
这是错误的。至少,通常是错误的。**仅仅因为某个东西听起来像一个对象,并不一定意味着它在您的程序中就应该是一个对象。**反射性地为应用程序中的每个概念编写类,往往会导致您得到一组相互关联的对象,它们各自都有自己的内部、不断变化的状态。这种程序通常难以理解,因此很容易出错。
相反,让我们将村庄的状态压缩到定义它的最小值集。有机器人的当前位置和未投递包裹的集合,每个包裹都有一个当前位置和一个目的地地址。仅此而已。
补充:这是作者对"过度 OOP"的当头一棒——听起来像对象不代表就该是对象。把状态压到最小:机器人位置 + 未投递包裹集(每个包裹有当前位置和目的地)。这和第 3 章 YAGNI、第 6 章"组合优于继承"一脉相承。
更关键的设计:不在机器人移动时改状态,而是算出移动后的新状态。
class VillageState {
constructor(place, parcels) {
this.place = place; // 机器人当前位置
this.parcels = parcels; // 未投递包裹 [{place, address}]
}
move(destination) {
if (!roadGraph[this.place].includes(destination)) {
return this; // 没这条路,原地不动
} else {
let parcels = this.parcels.map(p => {
if (p.place != this.place) return p; // 不在机器人这的包裹不动
return {place: destination, address: p.address}; // 跟着机器人走到新位置
}).filter(p => p.place != p.address); // 到目的地的投递掉(删掉)
return new VillageState(destination, parcels); // 返回新状态,旧状态不动
}
}
}
map负责移动包裹、filter负责投递。包裹不被改、被重新创建,move返回全新状态、旧状态完好。
move 方法是动作发生的地方。它首先检查从当前地点到目的地的道路是否存在,如果不存在,它会返回旧状态,因为这不是一个有效的移动。
接下来,该方法创建了一个新的状态,其中目的地是机器人的新位置。它还需要创建一个新的包裹集——机器人正在运送的包裹(位于机器人的当前位置)需要被移动到新位置。而寄往新位置的包裹需要被投递——也就是说,需要从未投递包裹集中删除。对 map 的调用负责移动,而对 filter 的调用负责投递。
包裹对象在移动时不会被更改,而是被重新创建。move 方法为我们提供了一个新的村庄状态,但完全保留了旧状态。
let first = new VillageState("Post Office",
[{place: "Post Office", address: "Alice's House"}]);
let next = first.move("Alice's House");
console.log(next.place); // → Alice's House
console.log(next.parcels); // → [] 包裹投递了
console.log(first.place); // → Post Office 旧状态还在
移动导致包裹被投递,这反映在下一个状态中。但初始状态仍然描述了机器人位于邮局,包裹未投递的情况。
补充:move 不改原状态、返回新状态,这是"不可变状态"——
first还在原地。map+filter造新数组(第 5 章那套),包裹对象也被重建而非修改。这让你能孤立地推理:“从状态 A 移动到 X 总是产生同一新状态”,不用操心时间维度的变化。【和 Java 里 StringBuilder 的可变、String 的不可变是同一个思路】
四、持久数据
不会更改的数据结构叫不可变或持久数据结构,行为像字符串和数字——它就是它,它们是它们本身,并且保持这种状态,不随时间变内容,在不同时间包含相同的内容。
在 JavaScript 中,几乎所有东西都可以被改变,因此使用应该持久的数值需要一些克制。
Object.freeze 函数会改变一个对象,以便对其属性的写入会被忽略:
作者:如果您想谨慎行事,可以使用它来确保您的对象不会被更改。冻结确实需要计算机做一些额外的工作,而且让更新被忽略与让它们做错事一样容易让人感到困惑。我通常更喜欢告诉人们某个给定对象不应该被弄乱,并希望他们能记住这一点。
let object = Object.freeze({value: 5});
object.value = 10; // 被忽略
console.log(object.value); // → 5
补充:
freeze有额外开销,且"让更新被忽略"和"让更新做错事"一样容易让人困惑。作者更倾向"告诉别人这对象别动、指望他们记住",不强制freeze。【比 Java 的final弱——final锁引用、freeze锁内容,两者正交】
为什么费尽心思不改对象?而语言显然希望我去改变对象?因为这有助于理解自己的程序。这再次与复杂性管理相关。当系统中的对象是固定的、稳定的东西时,我可以孤立地(隔离地)考虑对它们的运算——从给定状态移动到 Alice’s House 总是产生同一新状态。对象随时间的推移变化会给推理增加一个全新的复杂维度。
对于像本章中正在构建的这样的小型系统,我们可以处理这种额外的复杂性。但限制我们能够构建的系统类型的最重要的限制是我们的理解程度。任何使代码更容易理解的东西,都会使构建更复杂的系统成为可能。
不幸的是,尽管理解基于持久数据结构的系统更容易,但设计一个系统(尤其是在您的编程语言没有帮助的情况下)可能会更难一些。在本书中,我们将寻找使用持久数据结构的机会,但我们也会使用可变的数据结构。
补充:持久数据让"状态"变成可推理的值,而不是随时间漂移的流。小型系统能扛住可变性,但限制我们能造多复杂系统的,是理解程度——任何让代码更易懂的东西,都让更复杂系统成为可能。
五、模拟:runRobot 与随机机器人
送货机器人观察世界,并决定想要向哪个方向移动。因此,我们可以说机器人是一个函数,它接收一个 VillageState 对象并返回附近地点的名称。
因为我们希望机器人能够记住事物,以便它们可以制定和执行计划,所以我们也向它们传递它们的记忆,并允许它们返回新的记忆。因此,机器人返回的东西是一个包含它想要移动的方向和一个记忆值的 对象,该记忆值将在下次调用该机器人时传回给它。
机器人是函数:接
VillageState,返回"去哪个方向"。要记忆的话,还传记忆进去、返回新记忆。所以机器人返回{direction, memory}:
function runRobot(state, robot, memory) {
for (let turn = 0;; turn++) {
if (state.parcels.length == 0) {
console.log(`Done in ${turn} turns`);
break;
}
let action = robot(state, memory); // 机器人决策
state = state.move(action.direction); // 更新状态
memory = action.memory; // 更新记忆
console.log(`Moved to ${action.direction}`);
}
}
补充:
runRobot是模拟器——循环到包裹投完,每轮调机器人拿动作、用move更新状态、更新记忆。它把"机器人逻辑"和"世界模拟"解耦:机器人只管决策,状态转移交给VillageState.move。【和游戏主循环的 ECS 思路有点像:逻辑和状态分离】
随机机器人
考虑一下机器人必须做什么才能“解决”给定状态。它必须通过访问每个有包裹的位置来拾取所有包裹,然后通过访问包裹应该被投递到的每个位置来投递它们,但前提是必须在拾取包裹后才能投递它们。
什么是最愚蠢的策略?机器人可以每回合都随机地朝某个方向行走。这意味着,很有可能,它最终会遇到所有包裹,然后在某个时候也会到达包裹应该被投递的地方。
以下是它可能的样子
// 最蠢的策略——每回合随机走
function randomPick(array) {
// Math.random() 返回一个介于 0 和 1 之间的数字——但始终低于 1。将这样的数字乘以数组的长度,然后对其应用 Math.floor,会得到数组的随机索引。
let choice = Math.floor(Math.random() * array.length);
return array[choice];
}
function randomRobot(state) {
return {direction: randomPick(roadGraph[state.place])};
}
randomRobot不需要记忆,忽略第二参(JS函数能接额外参数无不良影响),并在其返回的对象中省略memory属性。
为了让这个精密的机器人开始工作,我们需要一种方法来创建一个包含一些包裹的新状态。静态方法(这里通过直接向构造函数添加属性来编写)是一个放置该功能的好地方。
创造初始状态的静态方法:
VillageState.random = function(parcelCount = 5) {
let parcels = [];
for (let i = 0; i < parcelCount; i++) {
let address = randomPick(Object.keys(roadGraph));
let place;
// 我们不希望有任何包裹从与它们地址相同的地址发送。因此,do 循环在获得等于地址的位置时会不断选择新位置。
do {
place = randomPick(Object.keys(roadGraph));
} while (place == address); // 寄件地不能等于收件地
parcels.push({place, address});
}
return new VillageState("Post Office", parcels);
};
补充:
VillageState.random直接挂在构造函数上——第 6 章说的"静态方法"的旧写法(操作函数属性,比static关键字早)。do...while保证寄件地 ≠ 收件地。
让我们启动一个虚拟世界。
runRobot(VillageState.random(), randomRobot);
// → Moved to Marketplace
// → Moved to Town Hall
// → …
// → Done in 63 turns
机器人花了许多回合才投递完包裹,因为它没有很好地提前计划。我们很快就会解决这个问题。(随机机器人花 63 回合——没提前规划,效率低。)
为了更直观地观察模拟过程,你可以使用 本章编程环境中 可用的 runRobotAnimation 函数。此函数会运行模拟,但它不会输出文本,而是会显示机器人在地图上移动。
runRobotAnimation(VillageState.random(), randomRobot);``runRobotAnimation的实现方式目前仍是个谜,但读完本书的后面的章节(讨论 JavaScript 在 Web 浏览器中的集成)后,你就能猜到它的工作原理了。
六、邮车路线:routeRobot
我们应该能够比随机机器人做得更好。一个简单的改进方法是借鉴现实世界中邮递员的做法。如果我们找到一条经过村庄所有地方的路线,机器人就可以跑两次这条路线,这样它就一定能完成任务。以下是一条这样的路线(从邮局开始)
const mailRoute = [
"Alice's House", "Cabin", "Alice's House", "Bob's House",
"Town Hall", "Daria's House", "Ernie's House",
"Grete's House", "Shop", "Grete's House", "Farm",
"Marketplace", "Post Office"
];
要实现路线跟踪机器人,需要使用记忆存"路线剩余部分",并且在每次转弯时丢弃第一个元素:
function routeRobot(state, memory) {
if (memory.length == 0) {
memory = mailRoute; // 记忆空了就重新装路线
}
return {direction: memory[0], memory: memory.slice(1)}; // 走第一步,记忆切掉它
}
这个机器人已经快多了。最多 26 回合(路线 13 步 ×2),但通常会更少,比随机的 63 强多了。
runRobotAnimation(VillageState.random(), routeRobot, []);
补充:
routeRobot用memory存剩余路线——记忆是机器人的"计划本"。slice(1)造新数组(第 4 章),不改原路线。
七、寻路:BFS 与 goalOrientedRobot
不过,我不会将盲目地遵循固定路线称为智能行为。如果机器人可以根据实际需要调整自己的行为,它可以更有效地工作。
为此,它必须能够有目的地移动到某个包裹的位置,或者移动到包裹需要送达的位置。即使目标距离超过一步,也要做到这一点,这需要某种寻路功能。
盲目走固定路线不算智能。要能"有目的地"去某包裹处或投递处,哪怕目标多步远——需要寻路。
在图中寻找路线的问题是典型的搜索问题。我们能判断给定的解决方案(一条路线)是否有效,但没法像算 2+2 那样直接计算解决方案。相反,我们只能不断构造潜在的解决方案(候选解)直到找到一个有效的。
图中可能的路线数量是无限的。但当搜索从 A 到 B 的路线时,我们只对从 A 出发的路线感兴趣。我们也不关心重复访问同一地点的路线,这些路线绝对不是最有效的路线。因此,这减少了寻路器需要考虑的路线数量。
事实上,由于我们主要对 最短 路线感兴趣,我们希望确保我们查看短路线而不是长路线。一个好的方法是从起点“扩展”路线,探索所有尚未访问的可到达地点,直到路线到达目标。这样,我们只探索可能感兴趣的路线,并且我们知道我们找到的第一条路线是最短路线(或者,如果有多条最短路线,则其中一条)。
从 A 到 B,只关心从 A 出发的、不绕回重复地点的路线,且要最短——广度优先:从起点扩展,探索所有未访问的可达地点,直到命中目标。这样找到的第一条就是最短的。
function findRoute(graph, from, to) {
let work = [{at: from, route: []}]; // 工作列表:待探索地点 + 到那的路线
for (let i = 0; i < work.length; i++) {
let {at, route} = work[i];
for (let place of graph[at]) {
if (place == to) return route.concat(place); // 命中目标,回溯出路线
if (!work.some(w => w.at == place)) { // 没探索过的才加,保证最短
work.push({at: place, route: route.concat(place)});
}
}
}
}
探索必须以正确的顺序进行,首先探索最早到达的地方。我们不能在到达某个地方后立即探索它,因为这意味着 从此处到达 的地方也会立即被探索,等等,即使可能存在其他尚未探索的更短路径。
因此,该函数会保留一个 工作列表。这是一个应该接下来探索的地方的数组,以及到达这些地方的路线。它只包含起始位置和一条空路线。
搜索通过从列表中获取下一个项目并探索该项目来进行,这意味着它会查看从该位置出发的所有道路。如果其中一条道路是目标,则可以返回一条已完成的路线。否则,如果我们以前没有查看过这个地方,则会将一个新项目添加到列表中。如果我们以前查看过,因为我们首先查看短路线,所以我们要么找到一条到达该地点的更长路线,要么找到一条与现有路线一样长的路线,我们不需要探索它。
你可以将其可视化为从起始位置扩展出来的已知路线网络,在所有方向上均匀扩展(但永远不会交叉回自身)。一旦第一个线程到达目标位置,该线程就会回溯到起点,从而提供我们的路线。
我们的代码没有处理工作列表中没有更多工作项的情况,因为我们知道我们的图是 连通的,这意味着从所有其他位置都可以到达每个位置。我们总是能够找到两个点之间的路线,搜索不会失败。
补充:这是广度优先搜索(BFS),用"工作列表"当队列(先进先出)。每个工作项是
{at: 当前地点, route: 到这的路线}。从起点逐层扩展,命中目标就回溯出路线;已探索的地点不再加(保证最短)。这在力扣上叫"图的 BFS 最短路径",模板题;图的连通性保证不会搜不到。
目标导向机器人
用记忆存"要走的路线",空了就规划下一条——去取第一个未投递的包裹,或送它:
function goalOrientedRobot({place, parcels}, route) {
if (route.length == 0) {
let parcel = parcels[0];
if (parcel.place != place) {
route = findRoute(roadGraph, place, parcel.place); // 还没取,去取
} else {
route = findRoute(roadGraph, place, parcel.address); // 取了,去送
}
}
return {direction: route[0], memory: route.slice(1)};
}
这个机器人使用它的记忆值作为要移动的方向列表,就像路线跟踪机器人一样。只要该列表为空,它就必须弄清楚接下来该做什么。它会获取集合中的第一个未送达包裹,如果该包裹尚未取走,它会规划一条前往该包裹的路线。如果包裹已经 取走,它仍然需要送达,因此机器人会创建一个前往送达地址的路线。
看看执行情况:
runRobotAnimation(VillageState.random(),
goalOrientedRobot, []);
它比 routeRobot 聪明在"按需规划"而非"死走固定路线",这个机器人通常大约在 16 次转弯内完成送达 5 个包裹的任务。这比 routeRobot 稍微好一点,但肯定不是最优的(只盯第一个包裹,不考虑全局)。我们将在练习中继续改进它。
补充:
goalOrientedRobot解构参数{place, parcels}——只取状态里它要的两字段。
八、练习
动手写,别只看。完整方案在 eloquent.javascript.ac.cn/code。
测量机器人
仅仅通过让机器人解决几个场景很难客观地比较它们。也许某个机器人碰巧获得了更简单的任务,或者它擅长的任务,而另一个机器人却没有。
写
compareRobots,跑 100 个相同任务,输出两机器人平均步数。公平:给两机器人同一个任务。
/*
编写 compareRobots,接收两个机器人(及初始记忆)。生成 100 个任务,让两机器人各解决。输出每个机器人每次任务的平均步数。为确保公平,给每个机器人分配相同的任务,而不是为每个机器人生成不同的任务。【控制变量】
*/
/*
测量函数可以在循环中生成新的状态,并计算每个机器人所用的步数。当它生成足够的测量值后,它可以使用 console.log 输出每个机器人的平均值,即所有步数之和除以测量值的总数。
*/
function compareRobots(robot1, memory1, robot2, memory2) {
let total1 = 0, total2 = 0, tasks = 100;
for (let i = 0; i < tasks; i++) {
let task = VillageState.random(); // 同一个任务给两个机器人
total1 += countSteps(robot1, memory1, task);
total2 += countSteps(robot2, memory2, task);
}
console.log(`Robot 1 average: ${total1 / tasks}`);
console.log(`Robot 2 average: ${total2 / tasks}`);
}
// 编写一个 runRobot 函数的变体,该函数不是将事件记录到控制台,而是返回机器人完成任务所用的步数
// runRobot 的"静默 + 计数"版,抽出来复用
function countSteps(robot, memory, state) {
for (let steps = 0;; steps++) {
if (state.parcels.length == 0) return steps;
let action = robot(state, memory);
state = state.move(action.direction);
memory = action.memory;
}
}
compareRobots(routeRobot, [], goalOrientedRobot, []);
批注:关键是"同一个
task给两个机器人"——先VillageState.random()生成任务,再分别跑。countSteps是runRobot的静默计数版,抽出来复用——呼应第 5 章"纯函数好组合":状态不可变,同一任务跑两次互不影响。
机器人的效率
你能编写一个比 goalOrientedRobot 更快地完成送货任务的机器人吗?如果你观察到机器人的行为,它做了哪些明显愚蠢的事情?如何改进这些问题?
写一个比
goalOrientedRobot更快的机器人。观察它做了哪些蠢事、怎么改进。
goalOrientedRobot 的主要局限性:一次只考虑一个包裹,只盯 parcels[0],可能机器人身边就有包裹却跑去远处取(可能经常会在村里来回走动);也不考虑顺路。【因为碰巧它正在查看的包裹位于地图的另一边,即使附近还有其他包裹。】
一个可能的解决方案是计算所有包裹的路线,然后选择最短的路线。如果有多条最短路线,还可以获得更好的结果,方法是优先选择前往取包裹而不是送达包裹的路线。
改进:每次从所有待处理包裹里挑"离机器人最近的"(BFS 距离最短):
/*
goalOrientedRobot 只盯 parcels[0],可能身边有包裹却跑去远处。
改进:每次从所有待处理包裹里挑离机器人最近的(BFS 距离最短)。
*/
function findShortestRoute(state) {
let {place, parcels} = state;
let shortest = null, minLen = Infinity;
for (let parcel of parcels) {
let target = parcel.place != place ? parcel.place : parcel.address; // 取 or 送
let route = findRoute(roadGraph, place, target);
if (route.length < minLen) { // 找最短的
minLen = route.length;
shortest = route;
}
}
return shortest;
}
function yourRobot(state, route) {
if (route.length == 0) {
route = findShortestRoute(state); // 每次挑最近的
}
return {direction: route[0], memory: route.slice(1)};
}
批注:改进点是把"盯第一个包裹"换成"挑最近的包裹"——贪心策略。
findRoute复用上面的 BFS 算距离,取route.length最小的。还能更优(考虑顺路批量取送),但贪心已比goalOrientedRobot快。用compareRobots验证。
持久组
标准 JavaScript 环境中提供的大多数数据结构不太适合持久使用。数组有 slice 和 concat 方法,这些方法允许我们轻松创建新的数组,而不会损坏旧数组。但例如,Set 没有用于创建添加或删除了项目的新集合的方法。
写
PGroup,类似第 6 章Group,存储一组值,但add/delete返回新的PGroup实例、旧实例不变。有个空实例PGroup.empty,可以作为起始值使用。
为什么只需要一个 PGroup.empty 值,而不是每次都使用一个创建新空映射的函数?
- “所有空组都是一样的”——空组内容都是 [],彼此无区别。
- “类的实例不会改变”——PGroup 不可变:add/delete 都返回新实例、不改自身。
- “从这个单一空组创建许多组,而不影响它”——PGroup.empty.add(“a”) 会返回新组 {a},但 empty 自己还是空。所以大家共用这一个 empty 不会互相污染。
合起来:既然空组都一样、又永不变,那就全局共享一个空实例就够了,没必要每次 new PGroup([]) 造新的(白费内存)。要是个可变的类,那就必须每次 new 新的,否则 A 改了 empty、B 也跟着遭殃——但 PGroup 不可变,没这问题。
▎Java 类比:就是 Collections.emptyList() 的思路——一个共享的不可变空实例,谁拿去用都安全。可变的 new ArrayList<>() 才必须每次 new。
/*
写 PGroup,类似第 6 章 Group,有 add/delete/has。但 add 返回新实例(加成员),delete 返回新实例(删给定成员),旧实例不变。
适用于任何类型值,而不仅仅是字符串。它不需要在使用大量值时高效。
构造函数不属于接口(尽管你肯定会想在内部使用它)。
有个空实例 `PGroup.empty` ,可以作为起始值使用。
*/
class PGroup {
// 要将empty属性添加到构造函数中,可以将其声明为静态属性。你只需要一个empty实例,因为所有空组都是一样的,而且类的实例不会改变。你可以从这个单一的空组中创建许多不同的组,而不会影响它。
static empty = new PGroup([]); // 静态字段,类定义时 new 一次、全局共享
// 表示成员值集的最方便的方法仍然是数组,因为数组很容易复制。
// 该类的构造函数可以接收这样的数组作为其参数,并将其存储为该实例的(唯一)属性。此数组永远不会更新。
constructor(members = []) { this.members = members; }
// 当将值添加到组中时,你可以创建一个新的组,其中包含原始数组的副本,并在该副本中添加该值(例如,使用 concat)。
add(value) {
if (this.has(value)) return this; // 已在,返回自己
return new PGroup(this.members.concat(value)); // concat 造新数组,返回新实例
}
// 当删除值时,你从数组中过滤掉它。
delete(value) {
return new PGroup(this.members.filter(v => v !== value)); // filter 造新数组
}
has(value) {
return this.members.includes(value);
}
}
PGroup.empty = new PGroup([]); // 单一空实例作起始值,类外给构造函数挂个属性
let a = PGroup.empty.add("a");
let ab = a.add("b");
let b = ab.delete("a");
console.log(b.has("b")); // → true
console.log(a.has("b")); // → false a 不受影响
console.log(b.has("a")); // → false
两种写法等价,区别只在组织方式:一个挂类外、一个写类内。static empty = new PGroup([]) 在类体求值时执行,那时 PGroup 名字已绑定,所以 new PGroup([]) 能正常工作。不是必须改,只是更符合"把接口声明放类里"的惯例。
批注:这正是本章"持久数据"的实战——
add/delete用concat/filter造新数组、返回新PGroup,旧实例不变。PGroup.empty只需一个(不可变,共享无风险);若每次new空实例反而浪费——这正是不变性的红利。【为什么只需一个empty?因为它不可变,谁拿去都不会改,共享安全】
九、本章速查
- 项目章节停理论、做程序:把前 6 章的函数/数据结构/对象串成一个完整程序。
buildGraph把道路数组转成"地点 → 可达地点数组"的图映射,用Object.create(null)避原型污染。VillageState用最小状态(机器人位置 + 包裹集),move不改原状态、返回新状态(map移动 +filter投递)。- 持久数据:对象不可变,
Object.freeze可强制但作者更靠约定;不可变让状态可推理、降低复杂度。 - 机器人是函数,返回
{direction, memory};runRobot是模拟器,解耦决策与状态转移。 - 三种策略:随机(~63 步)、固定路线
routeRobot(~26 步,记忆存路线)、目标导向goalOrientedRobot(~16 步,BFS 寻路)。 findRoute是 BFS:工作列表当队列,从起点逐层扩展,命中目标回溯出最短路线。- 设计哲学:别给每个概念都建类、状态压到最小、用不可变状态——反"过度 OOP"的实践。
| 概念 | 一句话 | 关键点 |
|---|---|---|
buildGraph | 道路数组 → 图映射 | Object.create(null) 避原型污染 |
VillageState | 最小状态 + 不可变 | move 返回新状态;map 移动 + filter 投递 |
| 持久数据 | 对象不可变 | Object.freeze 可选;让状态可推理 |
runRobot | 模拟器 | 解耦机器人决策与世界模拟 |
| 随机机器人 | 无记忆随机走 | ~63 步,基线 |
routeRobot | 固定路线 + 记忆 | ~26 步,记忆存剩余路线 |
goalOrientedRobot | BFS 按需寻路 | ~16 步,盯第一个包裹 |
findRoute | BFS 最短路径 | 工作列表当队列,逐层扩展 |
本章主线一句话:项目把前 6 章串成一个程序——用最小不可变状态建模、用纯函数转移状态、用 BFS 寻路决策,反"过度 OOP",靠持久数据让复杂系统可推理。
总结:
VillageState.move是不可变状态转移的范式(map+filter造新状态);findRoute是图 BFS 最短路径模板(工作列表当队列);项目体现了"状态压到最小 + 持久数据 + 纯函数"的可推理设计,和"组合优于继承、YAGNI"一脉相承。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)