字串與註解


在 ToyLang 裡,字串是基本型態,使用成對的單引號 '' 含括(在 ToyLang 中不使用雙引號來表示字串,理由後述),例如 'Justin' 是個字串,如果想要在字串中表示單引號,必須使用 \' 表示,例如:

println('My name is Justin')     # 字串使用 '' 
println('My name is \'Justin\'') # 顯示 My name is 'Justin'

ToyLang 定義了幾個 escape 表示:

  • \\ 反斜線
  • \' 單引號 '
  • \n 換行
  • \r 歸位
  • \t Tab

'' 含括的字串是基本型態,如果使用 typeof 函式的話,會傳回 'string'

println(typeof('Justin'))  # 顯示 string
println(typeof(123))       # 顯示 number
println(typeof(true))      # 顯示 boolean

以上也一併示範了 typeof 對數值與布林值分別會傳回 'number''boolean'

可以使用 + 來串接字串:

firstName = 'Justin'
lastName = 'Lin'
println(firstName + ' ' + lastName)

如果想要知道字串的長度,可以將之包裹為 String 物件,這時就有 length 方法可以使用:

name = new String('Justin')
println(name.length())      # 顯示 6

當然這有些不方便,因此若字串接上 . 運算子試圖進行方法呼叫時,會自動包裹為 String 實例:

println('Justin'.length())  # 顯示 6

基本型態字串與 String 實例是不同的,這可以從 typeof 的結果得知:

println(typeof('Justin'))             # 顯示 string
println(typeof(new String('Justin'))) # 顯示 String

類別本身定義的方法,可以透過 ownMethods 取得,String 上可以使用的方法,可以透過以下程式碼來得知:

String.ownMethods().forEach(println)

這會顯示以下的內容:

<Function init>
<Function toUpperCase>
<Function toLowerCase>
<Function toString>
<Function trim>
<Function charAt>
<Function charCodeAt>
<Function codePointAt>
<Function endsWith>
<Function startsWith>
<Function includes>
<Function indexOf>
<Function lastIndexOf>
<Function substring>
<Function slice>
<Function split>
<Function length>
<Function format>

這部份主要對應至 JavaScript 的 String,因此方法簽署大致上是相同的,format 方法則是可用來進行簡單的字串格式化,例如:

println('Hello {0}'.format('World'))
println(String.format('Hello {0}', 'World'))

如同〈數值與布林〉,要識別 ToyLang 程式碼中,哪些符號的組合代表字串,才能抽取出該符號組合,進一步建立為語法樹的節點,這看來簡單,因為字串中允許各種符號,然而,必須考慮 \'\n 等 escape 的問題,因而 Regular expression 可以是:

const TEXT_REGEX = /'((?:[^'\\]|\\'|\\\\|\\r|\\n|\\t)*)'/;

這個 Regular expression 只考慮單引號含括字串的情況,像 Python、JavaScript 等動態定型語言,也可以使用雙引號 "" 來表示字串,如果你也想要這麼做,可以將之試著加入至上頭的 Regular expression,然而,為了令 Regular expression 比較好讀一些,我暫時就沒這麼做了。

當比對到 ToyLang 寫的字串時,例如在 ToyLang 中寫 'Hello\tWorld',依上頭的 Regular expression 比對到的 JavaScript 字串值會是 'Hello\\tWorld'\t 這類控制字元有著實際作用,因而必須將 'Hello\\tWorld' 處理為 'Hello\tWorld',這可以在 expr_parser.js 中看到:

...
return Primitive.of(textTokenable.value
    .replace(/^\\r/, '\r')
    .replace(/([^\\])\\r/g, '$1\r')    
    .replace(/^\\n/, '\n')
    .replace(/([^\\])\\n/g, '$1\n')
    .replace(/^\\t/, '\t')
    .replace(/([^\\])\\t/g, '$1\t')
    .replace(/\\\\/g, '\\')
    .replace(/\\'/g, '\'')
...

話說,至今為止你看過幾次註解的運用,註解看似處理簡單,像是若為單獨的一行註解:

# 這是一個註解
println('Hello, World')  

剖析器處理時,只要看到 # 開頭,直接略過該行就好了,然而,若允許註解出現在程式碼之後呢?

println('Hello, World')  # 這是一個註解

也許你會認為,不就是 /#.*/ 比對出來,而後將之消去嗎?問題在於,如果註解前的程式碼中,有字串中包含 # 呢?

println('# 用來表示註解')  # 這是一個註解

要在程式碼後放單行註解的話,就不免遇到這類情況,你可以試著建立 Regular expression 看看,要考慮到的情況遠比你想像的複雜的多,我試著建立過幾個,然而總有考慮不周詳的情況,本來想寫程式來判斷了,不過後來找到〈Java Regex find Oracle Single Line comments Except in a String〉,根據當中的說明試著修改了一下,最後是使用 /^((?:(?!#|').|'(?:''|[^'])*')*)\s*#.*$/,說明可以在 tokenizer.js 中看到:

/* 
    Matching single line comments except in a string
    ref: https://stackoverflow.com/questions/8446064/java-regex-find-oracle-single-line-comments-except-in-a-string

    ^                    # match the start of the line
    (                    # start match group 1
    (?:                  #   start non-capturing group 1
        (?!#|').         #     if there's no '#' or single quote ahead, match any char
        |                #     OR
        '(?:''|[^'])*'   #     match a string literal
    )*                   #   end non-capturing group 1 and repeat it zero or more times
    )                    # end match group 1
    #.*$                 # match a comment all the way to the end of the line    
*/

在程式碼的處理上,使用了 replace 將抓到非註解部份,整個取代原有的行:

line.replace(/^((?:(?!#|').|'(?:''|[^'])*')*)\s*#.*$/, '$1')

字串基本型態,可以自動包裹為 String 實例的這個部份,主要必須知道 . 運算子是如何運作的,這部份之後文件才會說明,不過現階段可以知道的是,用來包裹基本型態的 Primitive 節點(位於 value.js 中),提供了一個 box 方法:

// number, text, boolean
class Primitive extends Value {
    constructor(value) {
        super();
        this.value = value;
    }

    toString() {
        return `${this.value}`;
    }

    // currently only support text
    box(context) {
        return newInstance(context, 'String', Primitive, this.value);
    }

    ...
}

. 運算子處理時,會呼叫這個 box 方法,目前 box 僅支援包裹為 String,因此,如果試著在數值、布林後接下 . 運算子呼叫方法是會出錯的(畢竟也沒定義對應的包裹類別,自然也不會有方法可以呼叫),有興趣的話,你可以試著定義對應的基本型態包裹類別。

有些函式或方法,底層直接呼叫了 JavaScript 環境的 API,它們被實現在 builtin 裏的相關模組之中,這部份設計為可以自由增減,日後文件談到物件導向的實現時,就會知道如何自行增加這些 API。

不過,老是將函式或方法,直接對應至底層 JavaScript 的 API 沒有意思,因而後來,除了真的必須得用到 JavaScript 環境的一些 API 之外,我就直接用 ToyLang 來寫函式或方法了,這些可以在 lib 之中看到。

你已經看過 ToyLang 中幾個 .js 的程式碼實現片段了,目的是讓你大致知道語法節點的長相,或者是一些處理語法時該注意的細節,通常也是書或文件中少提及的地方(像是註解怎麼處理)。

如果你沒有實現過語言的經驗,一下子要理解這麼多著實不易,這就是我留著 simple_lang.js 的原因。

建議試著從簡單的語言開始,另一個起點也許是〈Interpreter 模式〉,事實上,它也是 simple_lang.js 的起點。

深入理解運算原理》第二章使用 Ruby 實現了一個簡單的語言,雖然是側重在抽象語法樹的說明,然而對於什麼是語法節點、何為運算式、陳述句又該如何串接清楚交代,可以參考一下。

只不過,運算式可不只有加減乘除這幾個二元運算子,ToyLang 就有近 20 個運算子,包含了單元、二元與三元運算子,我之後會談談自己是怎麼處理這些運算子的!