程式語言 - Programming Language
I create Zerg more than years
程式語言大概可以是 21 世紀的新潮流,在 Wiki 上的列舉的為數眾多的程式語言, 在這個領域當中也包含了 Esoteric programming language 這類不是為了方面開發的語言: 這類型的語言通常是挑戰電腦或人類的極限而設計出來。 但這些語言都是為了包裝更底層的語言: Machine Code 。機械碼是最終指導 CPU 如何運作的語言。 這種語言如同字面上的意思是給機器閱讀的。相對於機械碼的上層語法則是 組合語言 , 他是一種 Human-Readable 的語言,可以透過 assembler 轉換成機械碼。 之前已經在 這裡 講過關於 assembly language 的事情了,接下來就講講一般高階語言的特性。
困難點 / difficulty
設計到後來發現設計一個可編譯的 (Compiled) 程式語言,困難點在於跨平台的處理: 同一個函數 (例如:print) 會因為底層的 OS 不同導致有不一樣的實作結果,也就是使用到不一樣的 syscall 、register 數量跟呼叫函數的用法等。在這種設計架構下,會遇到為了滿足新的功能而大幅修改底層能力的狀況, 所以會轉向先設計一個可以運作的直譯器 (Interpreter) 來確定 POC 是否可行: 也就是這個語言最低限度的能力 (數值運算、函數定義、物件宣告、條件式、流程控制)。而等到 POC 實作完成後,再利用這個程式語言重新運作 POC 確定這個 POC 可行,也就是達到 self-hosting 的能力。
因此,會重新設計流程:
- 決定程式本身的能力。也就是定義好最低限度的語法
- 先出可以 self-hosting 的 POC 程式碼
- 根據程式碼的需求來產生可以直譯器
低階程式語言
低階程式語言是一種概念,主要泛指提供少數或者不提供抽象概念的程式語言。這種語言因為缺乏抽象功能語法, 低階語言的其中一個代表就是 組合語言 :這種語言幾乎跟機械語言一致,僅提供少數抽象概念讓設計師使用。 也因為是低階語言缺少包裝抽象語法的功能,導致設計師需要花費更多的心力在程式本身而非設計上。
高階程式語言
高階程式語言是相對於組合語言的一個分類,表示語言本身已經封裝好若干常用的邏輯。 在低階語言當中,你需要針對不同的平台 (包含 CPU 架構、OS 等) 使用不同的方式來做到一樣的事情。像是在 linux / Mac OS 中,輸出字串到 STDIN 當中, 就需要使用不一樣的 system call 。高階語言就是將這些邏輯封裝起來, 用一樣的語法在不同的平台上完成一樣的事情。
從高階語言開始,就會有額外的 語法 來展現自己擁有的特色。每個程式語言, 都有自己想要解決的問題以及方式,而這都靠語法以及相對的內建函式庫來提供給 程式設計師 。
Syntax Sugar / Syntax Salt
[Syntax sugar][17] 大概是高階語法的一種特性,通常也代表著一個語法的精神:syntax sugar 並不影響程式的運作,但是他深深地影響程式設計師的開發流程。一個簡單的例子: 如何重複的執行一個邏輯,每次運作都稍微改變運作的結果。以[等差級數][18]當作例子,要獲得下一個值 (an) 就需要改變輸入值 (n)。用 C 語言當作例子,我們可以得到:
n = 0;
while (n <= 100) {
A[n] = 3 * n + 2;
n = n + 1;
}
在上面的例子中,我們可以產生一個陣列並且填滿我們需要的陣列。但在這種寫法中, 需要分別考慮到終止條件以及下個條件兩種問題。因此產生出另一種寫法:
for (n = 0; n <= 100; ++n) {
A[n] = 3 * n + 2;
}
在這種寫法中,我們可以在一行中同時考慮到起始條件、終止條件以及下個條件三個目的。這種 syntax sugar 可以讓程式設計師,清楚地表達他的設計邏輯,並且讓下次開發更快速瞭解到原本開發過程中 (即使是同一個人) 的設計邏輯。而在 Python 中則用另一種寫法,更清楚表達這種下一個的概念:
for n in range(0, 101):
A[n] = 3 * n + 2
而在 Python 中又提供了另一種 syntax sugar 可以同時建構 array 以及重複執行邏輯。利用 [List Comprehension][19] 的技巧,程式設計師就能夠用更簡潔的方式,同時表達兩個邏輯:
A = [ 3*n+2 for n in range(0, 101) ]
相反的,syntax salt 則是容易搞混程式設計師的邏輯,導致開發流程的不便以及後續維護上的困難, 一個常見的例子就是 C 語言中的 [goto][20] 語法。設計師可以藉由 goto 將程式邏輯跳轉到任意部份, 如果沒有警慎使用則會讓程式的邏輯難以理解。下面的程式就是一個刻意設計的邏輯:在這個例子中, 很難快速瞭解 ch 以及輸出之間的關聯性。
switch (ch) {
ONE:
case 'q': case 'a': case 'z':
printf('First Line\n');
goto THREE;
TWO:
case 'w': case 's': case 'x':
printf('Second Line\n');
goto ONE:
THREE:
case 'e': case 'd': case 'c':
printf('Third Line\n');
goto TWO;
END:
default:
break;
}
數學運算
從最基本的數學運算開始,高階程式語言都會封裝簡單的數學運算,降低開發時候的複雜度。 在低階語言的領域中,register 的數量是被控制的,如果是一個比較複雜的運算邏輯, 在低階語言其實是很難實作的,像是:
# 2 + 3 * 5 % 2 - 7 ^ 2
mov rax, 3
mov rcx, 5
imul rax, rcx # rax = 3 * 5
xor rdx
mov rcx, 2
idiv rcx # rdx = rax % 2
mov rax, 7
mov rcx, 2
xor rax, rcx # rax = 7 ^ 2
mov rcx, 2
add rcx, rdx # rcx = rdx + 2
sub rcx, rdx # rcx = rcx - rdx
在設計的過程中,需要仔細注意每一個 register 是否有被重複使用,並且控管 register 使用的數量。假如 register 已經不夠使用,還需要將計算過程中的暫存值先存入到 stack 當中。 這種開發方式,會讓程式設計師無法將全部心力放在主要的邏輯當中。
而高階語言就可以完全封裝其中的邏輯:在每個平台都可以得到一樣的結果,無論平台擁有 5 個 register ( x86 / 16 bits ) 還是擁有 31 個 General-purpose register 的 ARM AArch64 都會得到一樣的結果,而且都是透過一樣的語法來完成:
x = 2 + 3 * 5 % 2 - 7 ^ 2
副程式 (subroutine)
副程式 ( subroutine ) 是高階語法中的一個邏輯區塊,用一段程式來表示一個獨立邏輯, 常見的用法是用在函數的表達上。在低階語法中,定義一個副程式跟一般的程式並沒有太多的差異, 利用 call 來呼叫一個副程式並用 ret 來回到原本呼叫的下一個指令。然而也是因為一樣的理由, 副程式跟原本的邏輯並沒有顯著的差異:利用 call 可以跳到任意組合語言的任意指令,直到 ret 回到原本呼叫的下一個指令,這表示低階語言需要處理更多的狀況。
設計 subroutine 需要考慮到如何傳遞參數並得到執行後的結果。傳遞參數有兩種基本的思考方式: 儲存到 register 或者是 push 到 stack 當中,相同的執行完後的結果,也可以用一樣的方式傳遞結果。 然而在低階語言當中,register 隨時都會被存取,副程式無法確定呼叫當下是否 register 就是 caller 想要傳遞的參數。相同的,當 subroutine 結束時需要思考如何將結果傳遞回去。 下面就是一個 階乘函數 的錯誤例子:
# Factorial(x)
asm Factorial:
cmp rax, 0x01
jle _Factorial_end_
sub rax, 0x01
call Factorial
mov rcx, rax
imul rcx
asm _Factorial_end_:
ret
這個函數設計上是利用 rax 這個 register 來傳遞呼叫時的參數 (這是一種 遞迴 的寫法):
- 當 rax 小於或等於 0x01 的時候就跳轉 (jle) 到 _Factorial_end_ 並且結束呼叫。
- 在其他狀況,就將 rax 的值減 0x01 並解再次呼叫 Factorial 來取得下一個值得結果。
根據 case 1 的的設計,Factorial 會將結果存放到 rax 當中回傳回去。因此在 case 2 的會將結果先暫存到 rcx 當中,在呼叫 imul rcx 來更新 rax 值。 這個範例程式犯了一個低階語言常見的錯誤:並沒有將 register 先暫存導致 register 被重複使用,因此可能在多次呼叫 subroutine 可能會導致結果不如預期的狀況發生。 下面就是修改後的結果。
# Factorial (x) - Store rax to keep the logical
asm Factorial:
cmp rax, 0x01
jle _Factorial_end_
push rax # template save rax value
sub rax, 0x01
call Factorial
mov rcx, rax
pop rax # restore the original parameter
imul rcx
asm _Factorial_end_:
ret
然而這依然會有潛在的問題:呼叫這個函數,會修改到原本 rcx 的值。如果 caller 原本有使用到 rcx 的話,則會破壞原本設計邏輯。下面則是再次修改後的版本:
# Factorial (x) - Store all changed register
asm Factorial:
cmp rax, 0x01
jle _Factorial_end_
push rcx # save rcx since we change this in subroutine
push rax # template save rax value
sub rax, 0x01
call Factorial
mov rcx, rax
pop rax # restore the original parameter
imul rcx
pop rcx # restore rcx as original value
asm _Factorial_end_:
ret
然而在高階程式中,副程式中使用到的每一個變數都會被視為 local variables : 這個變數只會在這個 [scope][16] 發揮作用,也不用擔心程式在其他邏輯中執行時,會修改到變數的值。 相同的,變數值也不用擔心重複呼叫副程式而被修改:
int Factorial(int x) {
if (0x01 >= x) {
return x;
} else {
return x * Factorial(x-0x01);
}
}
[16]: https://en.wikipedia.org/wiki/Scope_(computer_science) ) [17]: https://en.wikipedia.org/wiki/Syntactic_sugar [18]: https://en.wikipedia.org/wiki/Arithmetic_progression [19]: https://en.wikipedia.org/wiki/List_comprehension [20]: https://en.wikipedia.org/wiki/Goto