21 KiB
Chapter 7Project: A Robot
[...] the question of whether Machines Can Think [...] is about as relevant as the question of whether Submarines Can Swim.
<footer>Edsger Dijkstra, <cite>The Threats to Computing Science</cite></footer>
在“项目”章节中,我会在短时间内停止向你讲述新理论,相反我们会一起完成一个项目。 学习编程理论是必要的,但阅读和理解实际的计划同样重要。
我们在本章中的项目是构建一个自动机,一个在虚拟世界中执行任务的小程序。 我们的自动机将是一个接送包裹的邮件递送机器人。
Meadowfield
Meadowfield 村不是很大。 它由 11 个地点和 14 条道路组成。 它可以用roads
数组来描述:
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);
function addEdge(from, to) {
if (graph[from] == null) {
graph[from] = [to];
} else {
graph[from].push(to);
}
}
for (let [from, to] of edges.map(r => r.split("-"))) {
addEdge(from, to);
addEdge(to, from);
}
return graph;
}
const roadGraph = buildGraph(roads);
给定边的数组,buildGraph
创建一个映射对象,该对象为每个节点存储连通节点的数组。
它使用split
方法,将形式为"Start-End"
的道路字符串,转换为两元素数组,包含起点和终点作为单个字符串。
任务
我们的机器人将在村庄周围移动。 在各个地方都有包裹,每个都寄往其他地方。 机器人在收到包裹时拾取包裹,并在抵达目的地时将其送达。
自动机必须在每个点决定下一步要去哪里。 所有包裹递送完成后,它就完成了任务。
为了能够模拟这个过程,我们必须定义一个可以描述它的虚拟世界。 这个模型告诉我们机器人在哪里以及包裹在哪里。 当机器人决定移到某处时,我们需要更新模型以反映新情况。
如果你正在考虑面向对象编程,你的第一个冲动可能是开始为世界中的各种元素定义对象。 一个机器人,一个包裹,也许还有一个地点。 然后,它们可以持有描述其当前状态的属性,例如某个位置的一堆包裹,我们可以在更新世界时改变这些属性。
这是错的。
至少,通常是这样。 一个东西听起来像一个对象,并不意味着它应该是你的程序中的一个对象。 为应用程序中的每个概念反射式编写类,往往会留下一系列互连对象,每个对象都有自己的内部的变化的状态。 这样的程序通常很难理解,因此很容易崩溃。
相反,让我们将村庄的状态压缩成定义它的值的最小集合。 机器人的当前位置和未送达的包裹集合,其中每个都拥有当前位置和目标地址。这样就够了。
当我们到达新地点时,让我们这样做,在机器人移动时不会改变这种状态,而是在移动之后为当前情况计算一个新状态。
class VillageState {
constructor(place, parcels) {
this.place = place;
this.parcels = parcels;
}
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);
}
}
}
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
会使包裹被送达,并在下一个状态中反映出来。 但最初的状态仍然描述机器人在邮局并且包裹未送达的情况。
持久性数据
不会改变的数据结构称为不变的(immutable)或持久性的(persistent)。 他们的表现很像字符串和数字,因为他们就是他们自己,并保持这种状态,而不是在不同的时间包含不同的东西。
在 JavaScript 中,几乎所有的东西都可以改变,所以使用应该持久性的值需要一些限制。 有一个叫做Object.freeze
的函数,它可以改变一个对象,使其忽略它的属性的写入。 如果你想要小心,你可以使用它来确保你的对象没有改变。 freeze
确实需要计算机做一些额外的工作,忽略更新可能会让一些人迷惑,让他们做错事。 所以我通常更喜欢告诉人们,不应该弄乱给定的对象,并希望他们记住它。
let object = Object.freeze({value: 5});
object.value = 10;
console.log(object.value);
// → 5
当语言显然期待我这样做时,为什么我不想改变对象?
因为它帮助我理解我的程序。 这又是关于复杂性管理。 当我的系统中的对象是固定的,稳定的东西时,我可以孤立地考虑操作它们 - 从给定的起始状态移动到爱丽丝的房子,始终会产生相同的新状态。 当对象随着时间而改变时,这就给这种推理增加了全新的复杂性。
对于小型系统,例如我们在本章中构建的东西,我们可以处理那些额外的复杂性。 但是我们可以建立什么样的系统,最重要的限制是我们能够理解多少。 任何让你的代码更容易理解的东西,都可以构建一个更加庞大的系统。
不幸的是,尽管理解构建在持久性数据结构上的系统比较容易,但设计一个,特别是当你的编程语言没有帮助时,可能会更难一些。 我们将在本书中寻找使用持久性数据结构的时机,但我们也将使用可变数据结构。
模拟
递送机器人观察世界并决定它想要移动的方向。 因此,我们可以说机器人是一个函数,接受VillageState
对象并返回附近地点的名称。
因为我们希望机器人能够记住东西,以便他们可以制定和执行计划,我们也会传递他们的记忆,并让他们返回一个新的记忆。 因此,机器人返回的东西是一个对象,包含它想要移动的方向,以及下次调用时将返回给它的记忆值。
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}`);
}
}
考虑一下机器人必须做些什么来“解决”一个给定的状态。 它必须通过访问拥有包裹的每个位置来拾取所有包裹,并通过访问包裹寄往的每个位置来递送,但只能在拾取包裹之后。
什么是可能有效的最愚蠢的策略? 机器人可以在每回合中,向随机方向行走。 这意味着很有可能它最终会碰到所有的包裹,然后也会在某个时候到达包裹应该送达的地方。
以下是可能的样子:
function randomPick(array) {
let choice = Math.floor(Math.random() * array.length);
return array[choice];
}
function randomRobot(state) {
return {direction: randomPick(roadGraph[state.place])};
}
请记住,Math.random()
返回 0 和 1 之间的数字,但总是小于 1。 将这样一个数乘以数组长度,然后将Math.floor
应用于它,向我们提供数组的随机索引。
由于这个机器人不需要记住任何东西,所以它忽略了它的第二个参数(记住,可以使用额外的参数调用 JavaScript 函数而不会产生不良影响)并省略返回对象中的memory
属性。
为了使这个复杂的机器人工作,我们首先需要一种方法来创建一些包裹的新状态。 静态方法(通过直接向构造函数添加一个属性来编写)是放置该函数的好地方。
VillageState.random = function(parcelCount = 5) {
let parcels = [];
for (let i = 0; i < parcelCount; i++) {
let address = randomPick(Object.keys(roadGraph));
let place;
do {
place = randomPick(Object.keys(roadGraph));
} while (place == address);
parcels.push({place, address});
}
return new VillageState("Post Office", parcels);
};
We don’t want any parcels that are sent from the same place that they are addressed to. For this reason, the do
loop keeps picking new places when it gets one that’s equal to the address.
Let’s start up a virtual world.
runRobot(VillageState.random(), randomRobot);
// → Moved to Marketplace
// → Moved to Town Hall
// → …
// → Done in 63 turns
It takes the robot a lot of turns to deliver the parcels, because it isn’t planning ahead very well. We’ll address that soon.
For a more pleasant perspective on the simulation, you can use the runRobotAnimation
function that’s available in this chapter’s programming environment. This runs the simulation, but instead of outputting text, it shows you the robot moving around the village map.
runRobotAnimation(VillageState.random(), randomRobot);
The way runRobotAnimation
is implemented will remain a mystery for now, but after you’ve read the later chapters of this book, which discuss JavaScript integration in web browsers, you’ll be able to guess how it works.
The mail truck’s route
We should be able to do a lot better than the random robot. An easy improvement would be to take a hint from the way real-world mail delivery works. If we find a route that passes all places in the village, the robot could run that route twice, at which point it is guaranteed to be done. Here is one such route (starting from the post office).
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"
];
To implement the route-following robot, we’ll need to make use of robot memory. The robot keeps the rest of its route in its memory and drops the first element every turn.
function routeRobot(state, memory) {
if (memory.length == 0) {
memory = mailRoute;
}
return {direction: memory[0], memory: memory.slice(1)};
}
This robot is a lot faster already. It’ll take a maximum of 26 turns (twice the 13-step route), but usually less.
runRobotAnimation(VillageState.random(), routeRobot, []);
Pathfinding
Still, I wouldn’t really call blindly following a fixed route intelligent behavior. The robot could work more efficiently if it adjusted its behavior to the actual work that needs to be done.
To do that, it has to be able to deliberately move towards a given parcel, or towards the location where a parcel has to be delivered. Doing that, even when the goal is more than one move away, will require some kind of route-finding function.
The problem of finding a route through a graph is a typical search problem. We can tell whether a given solution (a route) is a valid solution, but we can’t directly compute the solution the way we could for 2 + 2. Instead, we have to keep creating potential solutions until we find one that works.
The number of possible routes through a graph is infinite. But when searching for a route from A to B, we are interested only in the ones that start at A. We also don’t care about routes that visit the same place twice—those are definitely not the most efficient route anywhere. So that cuts down on the amount of routes that the route finder has to consider.
In fact, we are mostly interested in the shortest route. So we want to make sure we look at short routes before we look at longer ones. A good approach would be to “grow” routes from the starting point, exploring every reachable place that hasn’t been visited yet, until a route reaches the goal. That way, we’ll explore only routes that are potentially interesting, and find the shortest route (or one of the shortest routes, if there are more than one) to the goal.
Here is a function that does this:
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)});
}
}
}
}
The exploring has to be done in the right order—the places that were reached first have to be explored first. We can’t immediately explore a place as soon as we reach it, because that would mean places reached from there would also be explored immediately, and so on, even though there may be other, shorter paths that haven’t yet been explored.
Therefore, the function keeps a work list. This is an array of places that should be explored next, along with the route that got us there. It starts with just the start position and an empty route.
The search then operates by taking the next item in the list and exploring that, which means that all roads going from that place are looked at. If one of them is the goal, a finished route can be returned. Otherwise, if we haven’t looked at this place before, a new item is added to the list. If we have looked at it before, since we are looking at short routes first, we’ve found either a longer route to that place or one precisely as long as the existing one, and we don’t need to explore it.
You can visually imagine this as a web of known routes crawling out from the start location, growing evenly on all sides (but never tangling back into itself). As soon as the first thread reaches the goal location, that thread is traced back to the start, giving us our route.
Our code doesn’t handle the situation where there are no more work items on the work list, because we know that our graph is connected, meaning that every location can be reached from all other locations. We’ll always be able to find a route between two points, and the search can’t fail.
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)};
}
This robot uses its memory value as a list of directions to move in, just like the route-following robot. Whenever that list is empty, it has to figure out what to do next. It takes the first undelivered parcel in the set and, if that parcel hasn’t been picked up yet, plots a route towards it. If the parcel has been picked up, it still needs to be delivered, so the robot creates a route towards the delivery address instead.
Let’s see how it does.
runRobotAnimation(VillageState.random(),
goalOrientedRobot, []);
This robot usually finishes the task of delivering 5 parcels in around 16 turns. Slightly better than routeRobot
, but still definitely not optimal.
Exercises
Measuring a robot
It’s hard to objectively compare robots by just letting them solve a few scenarios. Maybe one robot just happened to get easier tasks, or the kind of tasks that it is good at, whereas the other didn’t.
Write a function compareRobots
that takes two robots (and their starting memory). It should generate 100 tasks and let each of the robots solve each of these tasks. When done, it should output the average number of steps each robot took per task.
For the sake of fairness, make sure that you give each task to both robots, rather than generating different tasks per robot.
function compareRobots(robot1, memory1, robot2, memory2) {
// Your code here
}
compareRobots(routeRobot, [], goalOrientedRobot, []);
You’ll have to write a variant of the runRobot
function that, instead of logging the events to the console, returns the number of steps the robot took to complete the task.
Your measurement function can then, in a loop, generate new states and count the steps each of the robots takes. When it has generated enough measurements, it can use console.log
to output the average for each robot, which is the total amount of steps taken divided by the number of measurements.
Robot efficiency
Can you write a robot that finishes the delivery task faster than goalOrientedRobot
? If you observe that robot’s behavior, what obviously stupid things does it do? How could those be improved?
If you solved the previous exercise, you might want to use your compareRobots
function to verify whether you improved the robot.
// Your code here
runRobotAnimation(VillageState.random(), yourRobot, memory);
The main limitation of goalOrientedRobot
is that it considers only one parcel at a time. It will often walk back and forth across the village because the parcel it happens to be looking at happens to be at the other side of the map, even if there are others much closer.
One possible solution would be to compute routes for all packages, and then take the shortest one. Even better results can be obtained, if there are multiple shortest routes, by preferring the ones that go to pick up a package instead of delivering a package.
Persistent group
Most data structures provided in a standard JavaScript environment aren’t very well suited for persistent use. Arrays have slice
and concat
methods, which allow us to easily create new arrays without damaging the old one. But Set
, for example, has no methods for creating a new set with an item added or removed.
Write a new class PGroup
, similar to the Group
class from Chapter 6, which stores a set of values. Like Group
, it has add
, delete
, and has
methods.
Its add
method, however, should return a new PGroup
instance with the given member added, and leave the old one unchanged. Similarly, delete
creates a new instance without a given member.
The class should work for values of any type, not just strings. It does not have to be efficient when used with large amounts of values.
The constructor shouldn’t be part of the class’ interface (though you’ll definitely want to use it internally). Instead, there is an empty instance, PGroup.empty
, that can be used as a starting value.
Why do you need only one PGroup.empty
value, rather than having a function that creates a new, empty map every time?
class PGroup {
// Your code here
}
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
console.log(b.has("a"));
// → false
The most convenient way to represent the set of member values is still an array, since those are easy to copy.
When a value is added to the group, you can create a new group with a copy of the original array that has the value added (for example, using concat
). When a value is deleted, you filter it from the array.
The class’ constructor can take such an array as argument, and store it as the instance’s (only) property. This array is never updated.
To add a property (empty
) to a constructor that is not a method, you have to add it to the constructor after the class definition, as a regular property.
You need only one empty
instance because all empty groups are the same and instances of the class don’t change. You can create many different groups from that single empty group without affecting it.