軟體是如何組織的?
傳統的電腦科學教育重視上一章節的算法分析,不過近年來寫軟體其實已經成為一種工程,接下來的內容將會著重在如何組織程式碼,達成易讀、好測試、好擴充、好維護的程式。
論「為什麼學校教不出好的程式設計師?」 丁光光: 我覺得這是個paradigm shift。我高中的時候,寫程式就意味著搞懂data structure, 搞懂algorithms, 會用程式語言;如果你還能用數學分析程式執行步驟,你就是老師心目中的超級好學生。當時,寫程式是一種「藝術」,所以,D. E. Knuth的The Art of Programming才會引領整個Computer Science達20年。而能寫出看起來「無懈可擊」、「無法增減一行程式碼」的程式,才是眾人心中的「神」。但現在,寫程式成為一種「工程」。我們除了使用的工具不一樣之外,我們其實跟木匠沒有什麼差別。能夠在空中畫出未來程式的模樣並取得客戶的確認,能夠跟一大群具有同樣技藝的人合作、並在每個專案中發展idioms並建立code review的標準,能夠從架構中切分出任何片段、撰寫、測試還要能不會搞砸系統。而這些能力,只有實作的經驗能夠教導你。而且,這些都是這十年來才被看重的能力。我那些在大學裡教書的同學,就我所知,沒有人瞭解這一塊的重要性,也就沒有人能夠教導。
12. 匿名函式
我們學過在程式語言中數據有不同類型,例如字串、數字等。這一節要教大家一個非常重要的概念,那就是函式也是一種數據類型。函式也是一種數據類型。函式也是一種數據類型。很重要所以說三遍。
以下的 Ruby 程式碼中,變數 x
就是一個函式變數。它的值 ->{ puts "Hello World"}
叫做匿名函式(或叫做 anonymous function, code block 或 lambda )。匿名函式要透過 .call
才會實際調用。
x = -> { puts "Hello World" }
x.call # 輸出 Hello World
->
等同於lambda
等同於Proc.new
,這三種寫法都可以。有小小的差異但目前先不管沒關系。
這裡 Ruby 的語法看起來有點奇怪,如果你有學過一點 JavaScript,以下是等同的 JavaScript 代碼:
x = function(){ console.log("Hello World") }
x() # 輸出 Hello World
其中 x
是一個函式變數,透過 x()
才會調用。在 JavaScript 中調用一個函式一定要加上括號,所以用 x()
表示觸發調用。但是在 Ruby 裡面,調用函式是可以省略括號的,因為沒辦法區分 x
跟 x()
的情況下,Ruby 需要用 .call
方法才會調用匿名函式。
把函式變數當作參數
既然函式也是一種數據類型,我們就可以將這個函式變數當作參數來傳遞,例如
foo = -> { puts "foo" }
def bar(x)
puts "bar"
x.call
end
bar(foo)
# 輸出
# bar
# foo
我們將 foo
變數當作參數,傳到 bar
裡面去,然後再調用它。注意到第一行宣告 foo
函式的時候,我們並沒有真的調用它,直到 x.call
時才調用它。因此是先輸出 bar,然後才輸出 foo。
這段代碼等同於以下的 JavaScript 代碼:
foo = function() { console.log("foo") }
function bar(x) {
console.log("bar")
x()
}
bar(foo)
直接內嵌的寫法
也可以直接用匿名函式放到參數裡面:
def bar(x)
puts "bar"
x.call
end
bar( ->{ puts "zoo" } )
# 輸出
# bar
# zoo
等同於
function bar(x) {
console.log("bar")
x()
}
bar(function(){
console.log("zoo")
})
有寫過 JQuery 的同學們應該很熟悉這種形式。不過 Ruby 的寫法似乎不太常見這樣寫,這是因為通常會簡化成這樣:
def bar
puts "bar"
yield
end
bar do
puts "foo"
end
# 或是 bar { puts "foo" }
看起來很熟悉了吧。如果參數列最後一個參數是匿名函式,那麽就會用這種簡化的寫法:傳入的匿名函式用 { ...}
或 do .... end
表示,然後在函式裡面用 yield
這個關鍵字來實際調用它。
通常單行的程式會用
{ ... }
的寫法,多行則會用do ... end
的寫法。這只是 Coding Style 慣例而已,作用是一樣的。
如果匿名函式接受參數的話,語法是這樣:
def bar
puts "bar"
yield("zoo")
end
bar do |x|
puts x
puts "foo"
end
# 輸出
# bar
# zoo
# foo
其中 yield("zoo")
會將 “zoo” 帶入匿名函式,也就是函式 bar
的 x
參數
另外,還有一種混合的寫法長這樣:
def bar(&block)
puts "bar"
block.call
end
bar do
puts "foo"
end
# 或是 bar { puts "foo" }
用 &block
可以具體化傳入的 {....}
或 do ... end
參數。不過除非你還會繼續在 bar 裡面調用另一個方法代入這個匿名函式,否則一般不需要這樣寫。
13. 匿名函式的應用
匿名函式是一種非常重要的概念,這種可以將函數當作參數的技巧,有非常多的 API 設計使用到,特別如果你是庫(Library)或框架(Framework)的作者的話,更會用到這個技巧。以下來看看幾種不同的應用:
這種技巧又叫做 Higher-order function 高階函數
pre-and post-processing
pre-and post-processing 的意思是前置和後置處理,將共享的前置和後置處理代碼抽取出來。以 Ruby 的開檔寫入為例,無論要寫入什麽,首先一定要打開檔案,完成後一定要調用 close
方法:
f = File.open("myfile.txt", 'w')
f.write("Lorem ipsum dolor sit amet")
f.write("Lorem ipsum dolor sit amet")
f.close
我們可以改用傳入 block 參數的寫法,這樣就會自動關檔了:
File.open("myfile.txt", 'w') do |f|
f.write("Lorem ipsum dolor sit amet")
f.write("Lorem ipsum dolor sit amet")
end
作為練習,假如我們模仿寫一個類似的方法,會長這樣:
def my_open_file(filename, file_mode)
f = File.open(filename, file_mode) # 前置處理
yield(f)
f.close # 後置處理
end
my_open_file("myfile.txt", 'w') do |f|
f.write("Lorem ipsum dolor sit amet")
f.write("Lorem ipsum dolor sit amet")
end
這個 my_open_file
方法就是一個通用的開檔方法,這樣的好處是不用寫 f.close
了,而且也不可能忘記。如果忘了寫 end
的話會語法錯誤。另一個好處是透過代碼縮進也更好閱讀瞭解這些代碼在一起的。
callback function
回調函數的意思是先挖好坑,讓調用這個 API 的人可以填要執行什麽。
例如 Rails ActiveRecord 可以註冊 callback 方法,在 save
前後做一些事情。我們在實戰應用章節「自訂 Model 網址」曾經用過 before_validation :generate_friendly_id, :on => :create
,這會在 save
前去調用 generate_friendly_id
方法。
這個原理就是回調函式,假如我們自己來寫一個練習看看,這個 save
會長這樣:
class MyRecord
def self.register_before_callback(&block)
@@before_callback = block
end
def self.register_after_callback(&block)
@@after_callback = block
end
def save
@@before_callback.call # 挖了一個坑
puts "save into DB"
@@after_callback.call # 挖了一個坑
end
end
上面的代碼屬於庫(Library)的通用代碼,你可以想像在 Rails 內部大概是這樣的。
接下來是我們實際使用的情況:
MyRecord.register_before_callback do # 填坑
puts "this is before callback"
end
MyRecord.register_after_callback do # 填坑
puts "this is before callback"
end
record = MyRecord.new
record.save
# 輸出
# this is before callback
# save into DB
# this is after callback
調用函式在 JavaScript 用的就更多了,例如綁事件:
$("div").click(function(){
console.log("click!")
})
這個 click
的參數就是一個回調函式用法,當我們真正 click 時,才會調用這個匿名函式。
currying function
既然函數也是一種數據,我們可以造一種函式讓他也回傳一個匿名函式:
def add(x)
->(y){ x + y }
end
add2 = add(2)
add3 = add(3)
puts add2.call(4) # 輸出 6
puts add3.call(6) # 輸出 9
這雖然不太實用,但是是個有趣的例子。
Closure 閉包特性
匿名函式有一個很重要的特性,那就是 Closure (閉包)。這個意思是它會將外面的作用域(scope)一起包進來,函式內可以讀取到函數外的變數,但是在匿名函式中新建立的變數,離開匿名函式後會清掉。
arr = [1,2,3]
outer = 1
arr.each do
puts outer # 可以讀取到外面的 outer 變數
inner = 4 # 新建立一個 inner 變數
end
inner # 錯誤 NameError,找不到 inner 這個變數
14. Combinator functions
另一個常見的應用是 Combinator functions,指的是處理容器的三個組合招數,讓我們一一道來:
映射 map
映射的意思就是將容器裡面的元素,一對一變換成另一個新的容器。
例如我們想將以下的 arr
元素每一個都加一,首先示範一下傳統的寫法:
arr = [1,2,3,4,5,6,7,8,9,10]
result = []
arr.each do |i|
result.push(i+1)
end
puts result
# 輸出 [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
改用 map
方法,這個方法接受一個匿名函式來做轉換:
arr = [1,2,3,4,5,6,7,8,9,10]
result = arr.map { |i| i+1 }
puts result
# 輸出 [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
過濾 filter
過濾就是將容器里的元素,根據某些條件過濾建立另一個容器。
例如過濾出所有偶數,首先示範一下傳統的寫法:
arr = [1,2,3,4,5,6,7,8,9,10]
result = []
arr.each do |i|
if (i % 2 == 0)
result.push(i);
end
end
puts result
# 輸出 [2, 4, 6, 8, 10]
改用 select
方法,這個方法接受一個匿名函式來決定條件:
arr = [1,2,3,4,5,6,7,8,9,10]
result = arr.select{ |i| i % 2 == 0 } # 這個匿名函式要回傳 true 或 false
puts result
歸納 reduce
歸納就是將一個容器里的元素,歸納成一個值:
例如加總好了,首先示範一下傳統的寫法:
arr = [1,2,3,4,5,6,7,8,9,10]
result = 0;
arr.each do |i|
result = result + i
end
puts result
# 輸出 55
改用 reduce
方法:
arr = [1,2,3,4,5,6,7,8,9,10]
result = arr.reduce(0) { |sum, i| sum + i }
puts result
result
比較難理解一點:reduce
的第一個參數 0
是初始值,然後這個匿名函式依序走訪容器,兩個參數 sum
跟 i
,前者 sum
是前一次循環的回傳結果。i
是這次走訪的元素。
找最大值其實也可以用 reduce
方法:
arr = [9, 2, 10, 6, 2, 4, 5, 6, 0, 4]
max = arr.reduce do |max, i|
if max > i
max
else
i
end
end
puts max
# 輸出 10
每次循環,匿名函式回傳的值就是下一次的 max
參數。
如果 reduce
沒給第一個參數,那容器的第一個元素會是初始值。
綜合應用
假如我們想從以下的數據找出小於 1000 的最大的金額,要怎麽找呢?
先示範傳統寫法:
tickets = [
{ name: "Ticket A", amount: 100 },
{ name: "Ticket B", amount: 1123 },
{ name: "Ticket C", amount: 670 },
{ name: "Ticket D", amount: 50 },
{ name: "Ticket E", amount: 990 },
]
result = tickets[0][:amount] # 一定要先在 code block 外面初始這個變數,如果放在裡面,出來就被清掉了
tickets.each do |ticket|
if (ticket[:amount] < 1000 && ticket[:amount] > result )
result = ticket[:amount];
end
end
puts result
# 輸出 990
改成剛剛學到的寫法:
tickets = [
{ name: "Ticket A", amount: 100 },
{ name: "Ticket B", amount: 1123 },
{ name: "Ticket C", amount: 670 },
{ name: "Ticket D", amount: 50 },
{ name: "Ticket E", amount: 990 },
]
result = tickets.map{ |t| t[:amount] }.select{ |a| a < 1000 }.reduce{ |x,y|
(x > y)? x : y }
puts result
# 輸出 990
一行就可以寫完,有沒有覺得很厲害呢。
練習作業
people = [
{ name: "Person 1", :age => 21 },
{ name: "Person 2", :age => 15 },
{ name: "Person 3", :age => 13 },
{ name: "Person 4", :age => 30 },
{ name: "Person 5", :age => 45 },
]
請用 Combinator Function 用一行代碼得到一個 Array 是其中大於 20 歲的人名(name),也就是 ["Person 1", "Person 4", "Person 5"]