1
0
mirror of https://github.com/apachecn/eloquent-js-3e-zh.git synced 2025-05-23 20:02:20 +00:00
wizardforcel ccf4b74ed0 7.
2018-05-05 10:35:45 +08:00

21 KiB
Raw Blame History

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>

![Picture of a package-delivery robot](img/chapter_picture_7.jpg)

在“项目”章节中,我会在短时间内停止向你讲述新理论,相反我们会一起完成一个项目。 学习编程理论是必要的,但阅读和理解实际的计划同样重要。

我们在本章中的项目是构建一个自动机,一个在虚拟世界中执行任务的小程序。 我们的自动机将是一个接送包裹的邮件递送机器人。

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"
];

The village of Meadowfield

村里的道路网络形成了一个图。 图是节点(村里的地点)与他们之间的边(道路)的集合。 这张图将成为我们的机器人在其中移动的世界。

字符串数组并不易于处理。 我们感兴趣的是,我们可以从特定地点到达的目的地。 让我们将道路列表转换为一个数据结构,对于每个地点,都会告诉我们从那里可以到达哪些地点。

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'的调用处理递送。

Parcel objects arent changed when they are moved, but recreated. The move method gives us a new village state, but leaves the old one entirely intact.

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

The move causes the parcel to be delivered, and this is reflected in the next state. But the initial state still describes the situation where the robot is at the post office and the parcel is undelivered.

Persistent data

Data structures that dont change are called immutable or persistent. They behave a lot like strings and numbers in that they are who they are, and stay that way, rather than containing different things at different times.

In JavaScript, just about everything can be changed, so working with values that are supposed to be persistent requires some restraint. There is a function called Object.freeze that changes an object so that writing to its properties is ignored. You could use that to make sure your objects arent changed, if you want to be careful. Freezing does require the computer to do some extra work, and having updates ignored is just about as likely to confuse someone as having them do the wrong thing. So I usually prefer to just tell people that a given object shouldnt be messed with, and hope they remember it.

let object = Object.freeze({value: 5});
object.value = 10;
console.log(object.value);
// → 5

Why am I going out of my way to not change objects when the language is obviously expecting me to?

Because it helps me understand my programs. This is about complexity management again. When the objects in my system are fixed, stable things, I can consider operations on them in isolation—moving to Alices house from a given start state always produces the same new state. When objects change over time, that adds a whole new dimension of complexity to this kind of reasoning.

For a small system like the one we are building in this chapter, we could handle that bit of extra complexity. But the most important limit on what kind of systems we can build is how much we can understand. Anything that makes your code easier to understand makes it possible to build a more ambitious system.

Unfortunately, although understanding a system built on persistent data structures is easier, designing one, especially when your programming language isnt helping, can be a little harder. Well look for opportunities to use persistent data structures in this book, but well also be using changeable ones.

Simulation

A delivery robot looks at the world and decides in which direction it wants to move. As such, we could say that a robot is a function that takes a VillageState object and returns the name of a nearby place.

Because we want robots to be able to remember things, so that they can make and execute plans, we also pass them their memory and allow them to return a new memory. Thus, the thing a robot returns is an object containing both the direction it wants to move in and a memory value that will be given back to it the next time it is called.

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}`);
  }
}

Consider what a robot has to do to “solve” a given state. It must pick up all parcels by visiting every location that has a parcel, and deliver them by visiting every location that a parcel is addressed to, but only after picking up the parcel.

What is the dumbest strategy that could possibly work? The robot could just walk in a random direction every turn. That means, with great likelihood, that it will eventually run into all parcels, and then also at some point reach the place where they should be delivered.

Heres what that could look like:

function randomPick(array) {
  let choice = Math.floor(Math.random() * array.length);
  return array[choice];
}

function randomRobot(state) {
  return {direction: randomPick(roadGraph[state.place])};
}

Remember that Math.random() returns a number between zero and one, but always below one. Multiplying such a number by the length of an array and then applying Math.floor to it gives us a random index for the array.

Since this robot does not need to remember anything, it ignores its second argument (remember that JavaScript functions can be called with extra arguments without ill effects) and omits the memory property in its returned object.

To put this sophisticated robot to work, well first need a way to create a new state with some parcels. A static method (written here by directly adding a property to the constructor) is a good place to put that functionality.

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 dont 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 thats equal to the address.

Lets 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 isnt planning ahead very well. Well address that soon.

For a more pleasant perspective on the simulation, you can use the runRobotAnimation function thats available in this chapters 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 youve read the later chapters of this book, which discuss JavaScript integration in web browsers, youll be able to guess how it works.

The mail trucks 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, well 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. Itll take a maximum of 26 turns (twice the 13-step route), but usually less.

runRobotAnimation(VillageState.random(), routeRobot, []);

Pathfinding

Still, I wouldnt 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 cant 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 dont 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 hasnt been visited yet, until a route reaches the goal. That way, well 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 cant 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 havent 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 havent 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, weve found either a longer route to that place or one precisely as long as the existing one, and we dont 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 doesnt 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. Well always be able to find a route between two points, and the search cant 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 hasnt 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.

Lets 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

Its 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 didnt.

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, []);

Youll 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 robots 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 arent 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 shouldnt be part of the class interface (though youll 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 instances (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 dont change. You can create many different groups from that single empty group without affecting it.