习题 48: 更进阶的使用者输入
最后更新于:2022-04-01 05:40:30
你的遊戲可能一路跑得很爽,不過你處理使用者輸入的方式肯定讓你不勝其煩了。每一個房間都需要一套自己的語句,而且只有使用者完全輸入正確後才能執行。你需要一個設備,它可以允許使用者以各種方式輸入語彙。例如下面的幾種表述都應該被支援才對:
* open door
* open the door
* go THROUGH the door
* punch bear
* Punch The Bear in the FACE
也就是說,如果使用者的輸入和常用英語很接近也應該是可以的,而你的遊戲要識別出它們的意思。為了達到這個目的,我們將寫一個模組專門做這件事情。這個模組裡邊會有若干個類,它們互相配合,接受使用者輸入,並且將使用者輸入轉換成你的遊戲可以識別的命令。
英語的簡單格式是這個樣子的:
* 單詞由空格隔開。
* 句子由單詞組成。
* 語法控制句子的含義。
所以最好的開始方式是先搞定如何得到使用者輸入的詞彙,並且判斷出它們是什麼。
## 我們的遊戲語彙
我在遊戲裡建立了下面這些語彙:
* 表示方向: north, south, east, west, down, up, left, right, back.
* 動詞: go, stop, kill, eat.
* 修飾詞: the, in, of, from, at, it
* 名詞: door, bear, princess, cabinet.
* 數字詞: 由 0-9 構成的數字。
說到名詞,我們會碰到一個小問題,那就是不一樣的房間會用到不一樣的一組名詞,不過讓我們先挑一小組出來寫程式,以後再做改進吧。
## 如何斷句
我們已經有了詞彙表,為了分析句子的意思,接下來我們需要找到一個斷句的方法。我們對於句子的定義是「空格隔開的單詞」,所以只要這樣就可以了:
~~~
stuff = gets.chomp()
words = stuff.split()
~~~
目前做到這樣就可以了,不過這招在相當一段時間內都不會有問題。
## 語彙結構
一旦我們知道瞭如何將句子轉化成詞彙列表,剩下的就是逐一檢查這些詞彙,看它們是什麼類型。為了達到這個目的,我們將用到一個非常便利的 Ruby 資料結構「struct」。「struct」其實就是一個可以把一串的 attrbutes 綁在一起的方式,使用 accessor 函式,但不需要寫一個複雜的 class。它的建立方式就像這樣:
~~~
Pair = Struct.new(:token, :word)
first_word = Pair.new("direction", "north")
second_word = Pair.new("verb", "go")
sentence = [first_word, second_word]
~~~
這建立了一對 (TOKEN, WORD) 可以讓你看到 word 和在裡面做事。
這只是一個例子,不過最後做出來的樣子也差不多。你接受使用者輸入,用split 將其分隔成單詞列表,然後分析這些單詞,識別它們的類型,最後重新組成一個句子。
## 掃描輸入資料
現在你要寫的是詞彙掃描器。這個掃描器會將使用者的輸入字符串當做參數,然後返回由多個(TOKEN, WORD) struct 組成的列表,這個列表實現類似句子的功能。如果一個單詞不在預定的詞彙表中,那它返回時 WORD 應該還在,但TOKEN 應該設置成一個專門的錯誤標記。這個錯誤標記將告訴使用者哪裡出錯了。
有趣的地方來了。我不會告訴你這些該怎樣做,但我會寫一個「單元測試(unit test)」,而你要把掃描器寫出來,並保證單元測試能夠正常通過。
## Exceptions And Numbers
有一件小事情我會先幫幫你,那就是數字轉換。為了做到這一點,我們會作一點弊,使用「異常(exceptions)」來做。「異常」指的是你運行某個函數時得到的錯誤。你的函數在碰到錯誤時,就會「提出(raise)」一個「異常」,然後你就要去處理(handle)這個異常。假如你在 IRB 裡寫了這些東西:
~~~
ruby-1.9.2-p180 :001 > Integer("hell")
ArgumentError: invalid value for Integer(): "hell"
from (irb):1:in `Integer'
from (irb):1
from /home/rob/.rvm/rubies/ruby-1.9.2-p180/bin/irb:16:in `<main>'
~~~
這個 `ArgumentError` 就是 `Integer()` 函式拋出的一個異常。因為你給`Integer()` 的參數不是一個數字。`Integer()`函數其實也可以傳回一個值來告訴你它碰到了錯誤,不過由於它只能傳回整數值,所以很難做到這一點。它不能返回-1,因為這也是一個數字。 `Integer()`沒有糾結在它「究竟應該返回什麼」上面,而是提出了一個叫做`TypeError`的異常,然後你只要處理這個異常就可以了。
處理異常的方法是使用 `begin` 和 `rescue` 這兩個關鍵字:
~~~
def convert_number(s)
begin
Integer(s)
rescue ArgumentError
nil
end
end
~~~
你把要試著運行的程式碼放到「begin」的區段裡,再將出錯後要運行的程式碼放到「except」區段裡。在這裡,我們要試著呼叫 `Integer()` 去處理某個可能是數字的東西,如果中間出了錯,我們就「rescue」這個錯誤,然後返回 「nil」。
在你寫的掃描器裡面,你應該使用這個函數來測試某個東西是不是數字。做完這個檢查,你就可以聲明這個單詞是一個錯誤單詞了。
## What You Should Test
這裡是你應該使用的測試檔案 `test/test_lexicon.rb`:
~~~
require 'test/unit'
require_relative "../lib/lexicon"
class LexiconTests < Test::Unit::TestCase
Pair = Lexicon::Pair
@@lexicon = Lexicon.new()
def test_directions()
assert_equal([Pair.new(:direction, 'north')], @@lexicon.scan("north"))
result = @@lexicon.scan("north south east")
assert_equal(result, [Pair.new(:direction, 'north'),
Pair.new(:direction, 'south'),
Pair.new(:direction, 'east')])
end
def test_verbs()
assert_equal(@@lexicon.scan("go"), [Pair.new(:verb, 'go')])
result = @@lexicon.scan("go kill eat")
assert_equal(result, [Pair.new(:verb, 'go'),
Pair.new(:verb, 'kill'),
Pair.new(:verb, 'eat')])
end
def test_stops()
assert_equal(@@lexicon.scan("the"), [Pair.new(:stop, 'the')])
result = @@lexicon.scan("the in of")
assert_equal(result, [Pair.new(:stop, 'the'),
Pair.new(:stop, 'in'),
Pair.new(:stop, 'of')])
end
def test_nouns()
assert_equal(@@lexicon.scan("bear"), [Pair.new(:noun, 'bear')])
result = @@lexicon.scan("bear princess")
assert_equal(result, [Pair.new(:noun, 'bear'),
Pair.new(:noun, 'princess')])
end
def test_numbers()
assert_equal(@@lexicon.scan("1234"), [Pair.new(:number, 1234)])
result = @@lexicon.scan("3 91234")
assert_equal(result, [Pair.new(:number, 3),
Pair.new(:number, 91234)])
end
def test_errors()
assert_equal(@@lexicon.scan("ASDFADFASDF"), [Pair.new(:error, 'ASDFADFASDF')])
result = @@lexicon.scan("bear IAS princess")
assert_equal(result, [Pair.new(:noun, 'bear'),
Pair.new(:error, 'IAS'),
Pair.new(:noun, 'princess')])
end
end
~~~
記住你要使用你的專案骨架來建立新專案項目,將這個 Test Case 寫下來(不許複製貼上!),然後編寫你的掃描器,直至所有的測試都能通過。注意細節並確認結果一切工作良好。
## 設計提示
集中一次實現一個測試,盡量保持簡單,只要把你的 `lexicon.rb` 詞彙表中所有的單詞放那裡就可以了。不要修改輸入的單詞表,不過你需要建立自己的新列表,裡邊包含你的語彙元組。另外,記得使用 `include?` 函式來檢查這些語彙陣列,以確認某個單詞是否在你的語彙表中。
## 加分習題
1. 改進單元測試,讓它覆蓋到更多的語彙。
2. 向語彙列表添加更多的語彙,並且更新單元測試程式碼。
3. 讓你的掃描器能夠識別任意大小寫的詞彙。更新你的單元測試以確認其功能。
4. 找出另外一種轉換為數字的方法。
5. 我的解決方案用了37 行程式碼,你的是更長還是更短呢?