JavaScript 編程精解 中文第三版 7、項目:機器人

來源: ApacheCN『JavaScript 編程精解 中文第三版』翻譯項目

原文:Project: A Robotjavascript

譯者:飛龍html

協議:CC BY-NC-SA 4.0java

自豪地採用谷歌翻譯git

[...] 置疑計算機能不能思考 [...] 就至關於置疑潛艇能不能游泳。github

艾茲格爾·迪科斯特拉,《計算機科學的威脅》apache

在「項目」章節中,我會在短期內中止向你講述新理論,相反咱們會一塊兒完成一個項目。 學習編程理論是必要的,但閱讀和理解實際的計劃一樣重要。編程

咱們在本章中的項目是構建一個自動機,一個在虛擬世界中執行任務的小程序。 咱們的自動機將是一個接送包裹的郵件遞送機器人。小程序

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

咱們不想要發往寄出地的任何包裹。 出於這個緣由,當do循環獲取與地址相同的地方時,它會繼續選擇新的地方。

讓咱們創建一個虛擬世界。

runRobot(VillageState.random(), randomRobot);
// → Moved to Marketplace
// → Moved to Town Hall
// → …
// → Done in 63 turns

機器人須要花費不少時間來交付包裹,由於它沒有很好規劃。 咱們很快就會解決。

爲了更好地理解模擬,你可使用本章編程環境中提供的runRobotAnimation函數。 這將運行模擬,但不是輸出文本,而是向你展現機器人在村莊地圖上移動。

runRobotAnimation(VillageState.random(), randomRobot);

runRobotAnimation的實現方式如今仍然是一個謎,可是在閱讀本書的後面的章節,討論 Web 瀏覽器中的 JavaScript 集成以後,你將可以猜到它的工做原理。

郵車的路線

咱們應該可以比隨機機器人作得更好。 一個簡單的改進就是從現實世界的郵件傳遞方式中得到提示。 若是咱們發現一條通過村莊全部地點的路線,機器人能夠通行該路線兩次,此時它保證可以完成。 這是一條這樣的路線(從郵局開始)。

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 步的路線的兩倍),但一般要少一些。

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

尋路

不過,我不會盲目遵循固定的智能尋路行爲。 若是機器人爲須要完成的實際工做調整行爲,它能夠更高效地工做。

爲此,它必須可以有針對性地朝着給定的包裹移動,或者朝着包裹必須送達的地點。 儘管如此,即便目標距離咱們不止一步,也須要某種尋路函數。

在圖上尋找路線的問題是一個典型的搜索問題。 咱們能夠判斷一個給定的解決方案(路線)是不是一個有效的解決方案,但咱們不能像 2 + 2 這樣,直接計算解決方案。 相反,咱們必須不斷建立潛在的解決方案,直到找到有效的解決方案。

圖上的可能路線是無限的。 可是當搜索AB的路線時,咱們只關注從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)});
      }
    }
  }
}

探索必須按照正確的順序完成 - 首先到達的地方必須首先探索。 咱們不能到達一個地方就當即探索,由於那樣意味着,從那裏到達的地方也會被當即探索,以此類推,儘管可能還有其餘更短的路徑還沒有被探索。

所以,該函數保留一個工做列表。 這是一系列應該探索的地方,以及讓咱們到那裏的路線。 它最開始只有起始位置和空路線。

而後,經過獲取列表中的下一個項目並進行探索,來執行搜索,這意味着,會查看從該地點起始的全部道路。 若是其中之一是目標,則能夠返回完成的路線。 不然,若是咱們之前沒有看過這個地方,就會在列表中添加一個新項目。 若是咱們以前看過它,由於咱們首先查看了短路線,咱們發現,到達那個地方的路線較長,或者與現有路線同樣長,咱們不須要探索它。

你能夠在視覺上將它想象成一個已知路線的網,從起始位置爬出來,在各個方向上均勻生長(但不會纏繞回去)。 只要第一條線到達目標位置,其它線就會退回起點,爲咱們提供路線。

咱們的代碼沒法處理工做列表中沒有更多工做項的狀況,由於咱們知道咱們的圖是連通的,這意味着能夠從其餘全部位置訪問每一個位置。 咱們始終可以找到兩點之間的路線,而且搜索不會失敗。

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

這個機器人一般在大約 16 個回合中,完成了送達 5 個包裹的任務。 略好於routeRobot,但仍然絕對不是最優的。

練習

測量機器人

很難經過讓機器人解決一些場景來客觀比較他們。 也許一個機器人碰巧獲得了更簡單的任務,或者它擅長的那種任務,而另外一個沒有。

編寫一個compareRobots,接受兩個機器人(和它們的起始記憶)。 它應該生成 100 個任務,並讓每一個機器人解決每一個這些任務。 完成後,它應輸出每一個機器人每一個任務的平均步數。

爲了公平起見,請確保你將每一個任務分配給兩個機器人,而不是爲每一個機器人生成不一樣的任務。

function compareRobots(robot1, memory1, robot2, memory2) {
  // Your code here
}

compareRobots(routeRobot, [], goalOrientedRobot, []);

機器人的效率

你能寫一個機器人,比goalOrientedRobot更快完成遞送任務嗎? 若是你觀察機器人的行爲,它會作什麼明顯愚蠢的事情?如何改進它們?

若是你解決了上一個練習,你可能打算使用compareRobots函數來驗證你是否改進了機器人。

// Your code here

runRobotAnimation(VillageState.random(), yourRobot, memory);

持久性分組

標準 JavaScript 環境中提供的大多數數據結構不太適合持久使用。 數組有sliceconcat方法,可讓咱們輕鬆建立新的數組而不會損壞舊數組。 可是Set沒有添加或刪除項目並建立新集合的方法。

編寫一個新的類PGroup,相似於第六章中的Group類,它存儲一組值。 像Group同樣,它具備adddeletehas方法。

然而,它的add方法應該返回一個新的PGroup實例,並添加給定的成員,並保持舊的不變。 與之相似,delete建立一個沒有給定成員的新實例。

該類應該適用於任何類型的值,而不只僅是字符串。 當與大量值一塊兒使用時,它不必定很是高效。

構造函數不該該是類接口的一部分(儘管你絕對會打算在內部使用它)。 相反,有一個空的實例PGroup.empty,可用做起始值。

爲何只須要一個PGroup.empty值,而不是每次都建立一個新的空分組?

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
相關文章
相關標籤/搜索