Learn Ruby The Hard Way

笨方法學 Ruby

習題 48: 更進階的使用者輸入

你的遊戲可能一路跑得很爽,不過你處理使用者輸入的方式肯定讓你不勝其煩了。每一個房間都需要一套自己的語句,而且只有使用者完全輸入正確後才能執行。你需要一個設備,它可以允許使用者以各種方式輸入語彙。例如下面的幾種表述都應該被支援才對:

  • 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 構成的數字。

說到名詞,我們會碰到一個小問題,那就是不一樣的房間會用到不一樣的一組名詞,不過讓我們先挑一小組出來寫程式,以後再做改進吧。

如何斷句

我們已經有了詞彙表,為了分析句子的意思,接下來我們需要找到一個斷句的方法。我們對於句子的定義是「空格隔開的單詞」,所以只要這樣就可以了:

1
2
stuff = gets.chomp()
words = stuff.split()

目前做到這樣就可以了,不過這招在相當一段時間內都不會有問題。

語彙結構

一旦我們知道瞭如何將句子轉化成詞彙列表,剩下的就是逐一檢查這些詞彙,看它們是什麼類型。為了達到這個目的,我們將用到一個非常便利的 Ruby 資料結構「struct」。「struct」其實就是一個可以把一串的 attrbutes 綁在一起的方式,使用 accessor 函式,但不需要寫一個複雜的 class。它的建立方式就像這樣:

1
2
3
4
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的異常,然後你只要處理這個異常就可以了。

處理異常的方法是使用 beginrescue 這兩個關鍵字:

1
2
3
4
5
6
7
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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 行程式碼,你的是更長還是更短呢?