Link Search Menu Expand Document

軟體是如何組織的?

傳統的電腦科學教育重視上一章節的算法分析,不過近年來寫軟體其實已經成為一種工程,接下來的內容將會著重在如何組織程式碼,達成易讀、好測試、好擴充、好維護的程式。

論「為什麼學校教不出好的程式設計師?」 丁光光: 我覺得這是個paradigm shift。我高中的時候,寫程式就意味著搞懂data structure, 搞懂algorithms, 會用程式語言;如果你還能用數學分析程式執行步驟,你就是老師心目中的超級好學生。當時,寫程式是一種「藝術」,所以,D. E. Knuth的The Art of Programming才會引領整個Computer Science達20年。而能寫出看起來「無懈可擊」、「無法增減一行程式碼」的程式,才是眾人心中的「神」。但現在,寫程式成為一種「工程」。我們除了使用的工具不一樣之外,我們其實跟木匠沒有什麼差別。能夠在空中畫出未來程式的模樣並取得客戶的確認,能夠跟一大群具有同樣技藝的人合作、並在每個專案中發展idioms並建立code review的標準,能夠從架構中切分出任何片段、撰寫、測試還要能不會搞砸系統。而這些能力,只有實作的經驗能夠教導你。而且,這些都是這十年來才被看重的能力。我那些在大學裡教書的同學,就我所知,沒有人瞭解這一塊的重要性,也就沒有人能夠教導。

12. 匿名函式

我們學過在程式語言中數據有不同類型,例如字串、數字等。這一節要教大家一個非常重要的概念,那就是函式也是一種數據類型。函式也是一種數據類型。函式也是一種數據類型。很重要所以說三遍。

又叫做 First-class function 頭等函數

以下的 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 裡面,調用函式是可以省略括號的,因為沒辦法區分 xx() 的情況下,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” 帶入匿名函式,也就是函式 barx 參數

另外,還有一種混合的寫法長這樣:

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 是初始值,然後這個匿名函式依序走訪容器,兩個參數 sumi,前者 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"]

FP 的補充資料


Copyright © 2010-2022 Wen-Tien Chang All Rights Reserved.