字串與註解

December 18, 2021

在 Toy Lang 裡,字串是基本型態,使用成對的單引號 '' 含括(在 Toy Lang 中不使用雙引號來表示字串,理由後述)。

Toy 語法

例如 'Justin' 是個字串,如果想要在字串中表示單引號,必須使用 \' 表示,例如:

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

Toy Lang 定義了幾個 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'))

Toy 實現

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

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

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

當比對到 Toy Lang 寫的字串時,例如在 Toy Lang 中寫 '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 之外,我就直接用 Toy Lang 來寫函式或方法了,這些可以在 lib 之中看到。

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

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

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

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

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