Yuanchieh's Blog

Yuanchieh's Blog

生命是長期而持續的累積

02 Apr 2021

【跨程式語言上手】Ruby 基礎教學

【跨程式語言上手】系列第一篇,最近換工作學習了 Golang / Ruby,可以說是設計理念處於對立面的程式語言,在學習中不斷拿兩者比較,找到很多有趣的地方
在這樣的過程中,慢慢找出自己上手新程式語言的 pattern,也就是逐步填補自我疑問的過程,像是「怎麼宣告變數?」「怎麼寫測試?」「api server / http request 怎麼發?」「併發或效能怎麼處理?」等等共通的疑惑點

坊間有很多善心人士的教學,但往往都缺一點我想了解的資訊,例如 Google 搜尋「Ruby 教學」的中文素材,跑出這幾個很棒的教學

  1. 高見龍大大的 為你自己學 Ruby on Rails
  2. Ruby on Rails 實戰聖經
  3. Ruby 官網的 二十分鐘 Ruby 體驗

Ruby 教學會被綁在 Rails 教學中或許是 Ruby 生態特有的現象,但普遍程式語言教學也都缺少

  1. Testing:包含 Testing Framework / Unit Test / Mock、Stub
  2. Module:Core Module / Local Module 怎麼載入
  3. Concurrency:底層如何處理併發/平行運算
  4. 程式語言多用於後端,所以我會在意寫 api server / http request 的感覺是怎樣

可以說這幾點都是比較進階/偏科的議題,但對我的工作很重要,也是這系列的起源,我想要重新寫一份對我自己來說完整的程式語言教學,過程中會拿我已經熟悉的程式語言如 Javascript 跟一點點的 Golang 做對比

目標會著重於有經驗的程式設計師,已經熟練任一程式語言,想要快速上手或是品味另一門語言的人

以下內容會包含

  1. Ruby 設計理念與起源
  2. 基礎語法
  3. 模組
  4. 測試
  5. Http / API 相關
  6. 其他補充

內容大量參考上附的參考資料,並融入自己的淺見,會隨著使用時間的拉長持續修改,有什麼不同的意見歡迎留言分享

1. Ruby 設計理念與起源

Ruby 是一門 Dynamic Language,運行在 Ruby Virtual Machine 上,本身是弱型別但沒有 JS 中隱式的轉型 (ex. 1 + “23”),在 3.0 加入 Type safety 工具 TypeProf 幫助檢查型別問題

透過範例簡單看一下 Ruby 幾個特別的設計理念

Ruby is designed to make programmers happy

出自於 The Philosophy of Ruby A Conversation with Yukihiro Matsumoto, Part I,Ruby 給予開發者很高的自由度

  1. 定義 symbol 在 Ruby 的框架下產生自己的 DSL,例如 sinatra 這個 web framework,看範例會以為根本不是 ruby 寫的
  2. 支援 Meta programming 可以在 Runtime 改變類別行為
  3. 可以複寫任意的方法,包含原生類別
  4. 同一種功能可以有非常多種寫法,光是迴圈可以用 while / for in / each / until / begin while 等

Seeing Everything as an Object

在 Ruby 的世界中,幾乎每一個變數都是物件,包含 1+2 也可以寫成 1.+(2),1 本身是 Integer 類別裡頭有 + 這個方法
這讓 Ruby 很適合 OOP,也帶來很多的彈性,像是在 operator overwrite

class Integer
  alias :plus :+
  def + (other)
    puts self.to_s + " is adding " + other.to_s
    self.plus other
  end
end

puts 1 + 2

# 輸出結果
# 1 is adding 2
# 3

Integer 是預設類別,Ruby 遇到類別重複宣告時會合併,接著我們在 Integer 宣告 plus 是原本 + 的別名,接著覆寫 + 先打印出 is adding 字串在回傳,在 Ruby 中預設 function 最後一行即使不顯式宣告也會 return

透過匿名函式支援 Functional Programming Style

在 Ruby 世界中,不像 Javascript / Golang 把 function 視為一等公民

在程式語言中,所謂的一等公民條件是

  1. 可以傳入 function 當作參數
  2. 可以被 function 當作 return 值
  3. 可以被儲存於資料結構中使用

但是 Ruby 也還是匿名函式的語法,大致如下

def sum(x)
  total = x
  proc { |y| x += y }
end

sum_five = sum 5
puts sum_five.call(5)
puts sum_five.call(5)
puts sum_five.call(5)

後續會有更詳細補充,但至少 Ruby 世界中也是可以做到 functional programming 的

綜合以上,Ruby 是一門彈性很大、很自由的語言,這是一把雙面刃,對於新手可能也不是這麼友善,畢竟有太多語法跟關鍵字要去熟悉

如何安裝

可以從官網下載安裝 Installing Ruby,或是先安裝 Ruby 版本管理工具如 RVM

套件管理

安裝完 ruby 後,也同時安裝了 gem,gem 是 ruby 套件管理工具,可以安裝或發佈自己的套件

gem 我一開始理解成 npm,但 npm 層級高了一些,例如 gem 並沒有做到版本控制的功能,gem + bundle 比較是 npm 的組合

詳細可參考 Ruby 的 Rvm VS Gem VS Bundler 的差別

基礎語法

變數宣告

  1. 不用宣告型別
  2. 變數可以改變型別
  3. 但是沒有隱式的型別轉換
  4. 變數的 scope 只有當前的 context,但要注意匿名函式會讀取當前的 context,並不會一直往上查找,除非用全域變數 $ 開頭
x = 123
x = "123"

x = 123 + "123" # 拋錯 TypeError (String can't be coerced into Integer)

sum = 5
def func
    puts sum # 拋錯
end

1.upto(5) { |i| sum += i } # 這樣是可以的,因為 block 是用宣告當前的 context

$sum = 5 # 全域變數可以
def func
    puts $sum 
end

命名規則

  1. 變數名稱常用蛇形命名法
  2. 變數全大寫代表常數,但是常數被改會有 warning 不會有錯誤
  3. Class/Module 名稱開頭大寫
naming_convetion = 123
FIVE = 5
FIVE = 4 #  warning: already initialized constant X

Symbols

建立唯一且不可變的物件,用 : 開頭,重複宣告都會指向同一份記憶體位置 (透過 object_id 識別),而字串每一次宣告都會在記憶體產生新的一份 String Object,如果是要單純用來識別 Symbol 效能會比 String 好上很多喔

hello = :hello
world = :hello
puts hello == world
puts hello.object_id == world.object_id #true

hello = "hello"
world = "hello"
puts hello == world
puts hello.object_id == world.object_id #false

Hash

Ruby 有 Hash,可以用 => 或 : 分隔 key value,但是兩者有很大的差異

  1. => 非常的自由,key 值可以是任意的值
  2. : 的 key 只能是 symbol,如果放字串會直接轉成 symbol
    要非常小心 string 跟 symbol 是不同的,實作上很容易踩到這個坑
a = { "123": "123" }
b = { "123" => "123" }

puts a["123"] # nil
puts a[:"123"] # "123"
puts b["123"] # "123"
puts b[:"123"] # nil

Ruby 中幾乎都是物件,有內建很多便利的方法

x = 1
puts x.methods # 列出所有 Integer 包含的 method
puts x.odd? # 是不是偶數
puts x.class # 類別
puts x.to_s # 轉成字串

陣列

arr = [1,2,3,4,5]

puts arr.include?(2)
puts arr.push 0
puts arr.pop

loop / control flow

條件式

基本的 if / elseif / else 與三元判斷式

x = 1
if x.odd?
  puts "x is odd"
elsif x.even?
  puts "x is even"
else
  puts "never happen"
end

puts (x.odd?) ? "x is odd":"x is even"

switch case

  1. 採用 case / when / else 語法,不用加 break
  2. case 如果沒有接參數,則 when 條件可以放 statement / 如果有接參數,則 when 條件放常數
  3. 如果希望 case when 結果賦值給變數,可以用 when … then
x = 1

case
when x.odd?
  puts "x is odd"
when x.even?
  puts "x is even"
else
  puts "never happen"
end

case x
when 1..10
  puts "x is in 1 to 10"
else
  puts "x is not in 1 to 10"
end

val = case x
when 1..10
  then "x is in 1 to 10"
else
  "x is not in 1 to 10"
end
# val = "x is in 1 to 10"

迴圈

方式很多種

  1. 先檢查條件的 for in / while / until
  2. 後檢查條件的 begin … (when/until)
  3. Enumerable 物件可以用 each

沒有常見 for(initialExpression; conditionExpression; incrementExpression) 宣告

arr = [1,2,3,4,5]
i = 0

while i < arr.size do
  puts arr[i]
  i += 1
end

i=0
until i >= arr.size
  puts arr[i]
  i += 1
end

i=0
begin
  puts arr[i]
  i += 1
end while i < arr.size


for i in arr do
  puts i
end

arr.each { |x| puts x }

Error handling

  1. 透過 raise 拋出錯誤
  2. 透過 rescue 接錯誤,可以更進階指定錯誤類型
begin
  #... process, may raise an exception
  raise ArgumentError
rescue ArgumentError
  puts "ArgumentError"
rescue => error
  puts error
  #... error handler
else
  #... executes when no error
ensure
  #... always executed
end

進階資料請參考 How to Rescue Exceptions in Ruby

Function

  1. function 不用定義回傳值
  2. 呼叫可以省略括號
  3. 預設最後一行會回傳,不用在顯示宣告 return
  4. 如果呼叫的參數數量跟宣告不同會拋出錯誤
def add (a, b)
  a + b # 等同於 return a + b
end

puts add 1,2

Class

  1. 預設 Class 名稱開頭大寫
  2. 支援繼承 Successor < Predecessor
  3. 要建立 instance 透過 Class.new,會呼叫 class 中的 private method initialize
  4. 宣告 instance 變數以 @ 開頭 / 宣告 class static 變數用 @@
  5. 需要顯式指定針對 instance 變數的 getter/setter,或是用 attr_accessor/attr_writer/attr_reader 增加
  6. 支援 public/protected/private,但跟其他語言的 private 不太同,以下節錄自高見龍大大的文章

因為在 Ruby 裡所謂的 private 方法的使用規定很簡單,就只有一條:「不能明確的指出 receiver」。用白話文講,就是「在呼叫 private 方法的時候,前面不可以有小數點」。也就是因為這樣,在 Ruby 的 private 方法其實不只類別自己內部可以存取,它的子類別也可以,並沒有像其它程式語言一樣的繼承限制

  1. 定義 static method 可以用 def self.method,或是用 class << self
  2. 沒有 interface / method overloading / polymorphism
class Person
  attr_accessor :name
  def initialize(name, age)
    @name = name
    @age = age
  end

  def hello
    puts "Hello, my name is #{@name}"
  end

  def self.show_specy
    puts "we are Mammals"
  end

=begin 等同於上者,此方法適和於大量定義
  class << self
    def show_specy
        puts "we are Mammals"
    end
  end 
=end

  protected
  def my_protected_method
    puts "my protected method"
  end

  private
  def my_little_secret
    puts "private methods"
  end
end

Person.show_specy
p1 = Person.new("yoyo", 10)
puts p1.name
p1.name = "hello"
# puts p1.age 對應第5點,不能直接呼叫 .age 取得 age 變數

class Teacher < Person
  def initialize(name, age, major)
    super(name, age)
    @major = major
  end

  def can_access_parent_private_method
    self.my_protected_method
    my_little_secret # 這樣可以讀取 parent private method
    #self.my_little_secret 
  end
end

t1 = Teacher.new("Mark", 10, "English")
t1.hello
t1.can_access_parent_private_method

t1.send :my_little_secret
t1.send :my_protected_method

Ruby 物件比想像中複雜,尤其是支援 Meta programming,進階資料可以參考 Ruby 的繼承鍊 (1) 物件導向如何實踐

匿名函式 block / Proc / lambda

  1. block 代表程式碼區塊,少數 Ruby 中不是物件的存在,必須依附在 function 上,透過 yeild 呼叫 block 執行,單行宣告用 {...},多行用 do ... end
  2. Proc 是物件,不限制參數,return 時是代表當時的 context return,透過 proc.call 執行
  3. lambda 是特殊的 Proc,會嚴格檢查參數,return 就如同一般的 function return
1.upto(5).map {|ele| puts ele}

# 可以判斷是不是有 block 被傳入,如果有則用 yeild 呼叫執行
def hello
  if block_given?
    yield("world")
  end
end

hello do |x|
  puts "message from hello: #{x}"
end

# Proc / lambda 可以儲存於變數備用
my_proc = Proc.new{ |x|
  return puts "from proc #{x}"
}

my_lambda = lambda { |x|
  puts "from lambda #{x}"
}

def func(block)
  puts "before call"
  block.call("func")
  puts "after call"
end


func(my_lambda) # before call/n from lambda func/n after call

# proc 宣告於最上層 context,所以 return 時會連帶結束整個程式
func(my_proc) # before call/n from proc func

有趣的語法 &:symbol

網路上有些說法是 {|x| x[:symbol]} 的縮寫,讓我們看下去

class Person
  attr_reader :name
  def initialize(name)
    @name = name
  end
end

a = [ Person.new("123"), Person.new("2222") ]

name_list = a.map { |x| x.name } # 兩者相同
name_list = a.map(&:name) 
p name_list
  1. 參數用 & 開頭代表參數是以 Proc 傳入,他跟一般的參數 args是切開的
def some_method(*args, &block)
  puts "args: #{args.inspect}"
  puts "block: #{block.inspect}"
end
some_method(1,2,3, :whatever)
# args: [1,2,3, :whatever]
# block: nil
some_method(1,2,3, &:whatever)
# args: [1,2,3]
# block: #<Proc:0x007fd23d010da8>
  1. 在 Ruby 呼叫 object method 可以用 send 的方式
class Person
  attr_reader :name
  def initialize(name)
    @name = name
  end
end
p Person.new("123").name == Person.new("123").send(:name)
  1. &:symbol 實際上會去呼叫 :symbol#to_proc ,而在 Symbol 中有定義 to_proc 行為,也會有人去自定義 class 中的 to_proc 方法
class Symbol
  def to_proc
    Proc.new do |receiver|
      receiver.send self
    end
  end
end

綜合上述,可以拆解成

a = [ Person.new("123"), Person.new("2222") ]

puts a.map(&:name)
# 可以拆解成下者
puts a.map { |x|
  x.send(:name)
}

也就是 receiver 會收到 symbol 的方法呼叫,參考自 What does map(&:name) mean in Ruby?

其他優秀的資訊

  1. Ruby 探索:Blocks 深入淺出
  2. [Ruby] 如何理解 Ruby Block

Concurrency & Parallelism

  1. Ruby 支援 Process ,可用於處理 CPU-Heavy issue

Ruby 在 2.0 導入 Cope on Write,當 process fork 時如果 value 沒有改動則使用同一份記憶體空間,降低 fork 對於記憶體資源無謂的佔用

  1. Ruby 支援 Thread,適用於處理 IO Event,但如果是 CPU-Heavy issue 則沒有幫助,因為 Ruby 有 GIL (Global Interpreter Lokc) 所以無法併發,一次只能執行一個 thread,這跟 Ruby VM 實作有關,如果是 JRuby 則沒有此問題

Thread releases GIL when it hits blocking I/O operations such as HTTP requests, DB queries, writing / reading from disk and even sleep

require 'benchmark'

ELE_AMOUNT = 1000
PROCESS_NUM = 2
arr = Array.new(ELE_AMOUNT) { Array.new(ELE_AMOUNT){rand(1...9)}}

Benchmark.bm(10) do |bm|
  bm.report("seq") do
    total = arr.reduce(0) do |sum, ele|
      sum + ele.sum
    end
  end

  bm.report("parallel") do
    read, write = IO.pipe
    1.upto(PROCESS_NUM).map do |i|
      Process.fork do
        p i
        step = (ELE_AMOUNT * 1.0 / PROCESS_NUM).ceil
        start_ele = step * (i-1)
        total = arr[start_ele, step].reduce(0) do |sum, ele|
          sum + ele.sum
        end
        write.puts total
      end
    end
    Process.wait
  end

  bm.report("thread") do
    total = 0
    threads = []
    1.upto(PROCESS_NUM).map do |i|
      t = Thread.new do
        step = (ELE_AMOUNT * 1.0 / PROCESS_NUM).ceil
        start_ele = step * (i-1)
        total += arr[start_ele, step].reduce(0) do |sum, ele|
          sum + ele.sum
        end
      end
      threads << t
    end
    threads.each(&:join)
  end
end
  1. Fiber 是類似於 goroutine 的概念,更輕量的 user space thread,主要用來非同步的排程,適用於結合 Non-blocking IO,因為 Fiber 在 Context Switch 比 Thread 更為輕量
    範例來源:Introduction to Concurrency Models with Ruby. Part I
EventMachine.run do
  Fiber.new {
    page = http_get('http://www.google.com/')     
    if page.response_header.status == 200
      about = http_get('https://google.ca/search?q=universe.com') 
      # ... 
    else 
      puts "Google is down"
    end  
  }.resume 
end
def http_get(url)
  current_fiber = Fiber.current
  http = EM::HttpRequest.new(url).get    
  http.callback { current_fiber.resume(http) }   
  http.errback  { current_fiber.resume(http) }    
  Fiber.yield
end
  1. Ruby 3.0 導入了基於 Actor 模式的 Ractor ,真正能做到利用 Thread 達成 Parallelism,以前會需要 GIL 是為了避免 multi thread 之下的 deadlock / race condition 狀況,但是在 Ractor 中基本上 Object 都不會被共享,參考 Share Memory By Communicating 有些文章會寫 Guild,但我查 Ruby 官方文件只有看到 Ractor

Do not communicate by sharing memory; instead, share memory by communicating.
意即如果希望在多個 Thread 中共享資訊,不要透過共享記憶體來溝通,而是透過通信交換資料達到共享資料的目的
因為共享記憶體就必須處理 lock,接著就要擔心 dead lock 等問題
如果不共享記憶體,直接將資料透過通道等方式傳遞,就不用擔心以上的問題,也可以更好的並行運算

優良的資源參考

  1. Ruby Concurrency and Parallelism: A Practical Tutorial
  2. Introduction to Concurrency Models with Ruby. Part I / II
  3. RubyConf Taiwan 2019 - The Journey to One Million by Samuel Williams

Module

  1. Module 提供類似於 Namespace 角色,可以自定義方法與常數不用擔心與其他人衝突
  2. Module 不能被實體化 (也就是不能被 new),主要透過 mixin 擴充 Class 在共享實作方面,Ruby 一個 Class 只能繼承一個 Parent,但是 Module 可以 mixin 多個,在沒有直接關係的情況下想要跨多個 Class 共享某些特定的方法,Module 是不錯的選擇
  3. 使用 Mixin 要小心多個 Module 可能會有不預期的互相干擾,例如修改同一個 instance 變數,有狀態要紀錄記得取一個比較特殊的名稱
  4. 如果 Module 中有 Class 宣告,要指定該 Class 可以用雙冒號 :: 連接例如 Module Name::Class Name
##### calculator.rb
module Greeting
  def sayhi
    puts "hello, #{@name}"
  end
  
  class Hi
  end
end

###### main.rb
require_relative './calculator'

class Person < Greeting::Hi
  # 透過 include 達到 mixin 效果
  include Greeting
  def initialize(name)
    @name = name
  end
end

p = Person.new("yoyo")
p.sayhi

Module 除了可以被 include 外,還有 extend 跟 prepend,請參考 Ruby 的繼承鍊 (2) — Module 的 include、prepend 和 extend

程式碼拆分檔案

把全部程式碼塞在同一個檔案十分的可怕,透過適時的拆分可以讓程式碼更好維護,在 Ruby 中如果要 include 其他檔案的宣告,可以用 require_relative '檔案本身的相對路徑'的方式
目前看起來只有常數、Module、Class 會被自動 export,還再找到相關的文件說明

require 總共有幾種

  1. require
  • 如果是相對路徑,則根據 $LOCAL_PAHT 設定去找對應的 library,通常是用來找外部相依或是 gem
  • 如果是絕對路徑,則直接載入對應檔案
  1. require_local:
    透過檔案的相對路徑找到檔案,主要是用於在自己專案中的其他檔案

Testing

Ruby 並沒有內建的 Test Framework,評估後選用 RSpec,提供 TDD/BDD Style 語法,透過 describe / it 組合測試案例

  1. 慣例是專案根目錄建立 spec 目錄,待測項目對應檔案名加上 _spec 結尾

Unit Test

  1. 支援 before / after / around(before + each),執行頻率分成 each / all / suite

suite 是在整個 test file 只會跑一次,all 則是每個 describe 都會執行一次

  1. 如果是有變數要在每一次 test case 前執行,可以用 before(:each),或是用 let(變數名稱){回傳值}

以下是一個簡單的運費計算

require('rspec')
require_relative '../calculator'

describe Calculator do
  let(:calculator) { Calculator.new }
  it 'small package should get $100 fee' do
    expect(calculator.fee(1, 5)).to eq(100)
  end

  it 'medium size should get $500 fee' do
    expect(calculator.fee(10, 5)).to eq(500)
  end

  before do
    @calculator = Calculator.new
  end
  around do |t|
    p "before each test"
    t.run
    p "after each test"
  end
  it 'large size should get $700 fee' do
    expect(@calculator.fee(100, 10)).to eq(750)
  end
end

Stub / Mock / Spy

RSpec 提供 mock 方法叫做 double (出自於 stunt double 演員替身)

  1. double 可以憑空產生物件,可以自定義 function 與回傳值
  2. double 可以只覆寫特定方法的回傳值 allow(instance).to receive(method).and_return(value)
  3. spy 不改方法的實作,只確認有沒有被呼叫,以及確認傳入的參數以及呼叫順序是否如預期
  4. 每一個 test case 後都會被自動 restore
  5. 因為是動態語言,所以要 “double(string)” 等方法都是可以的

運費計算加上一個 VIP 檢查

class VIP_Service
  def is_vip?
    raise "do some query"
  end
end

describe 
  let(:calculator) do
    vip_service = VIP_Service.new
    # 這裡透過 mock 動態改變
    allow(vip_service).to receive(:is_vip?).and_return(false)
    Calculator.new(vip_service)
  end
end 

spy 的案例請看官方文件範例

require 'rspec'

class Invitation
  def deliver(email)
    p email
  end
end

describe "Invitation" do
  let(:invitation) { spy("invitation") }

  before do
    invitation.deliver("foo@example.com")
    invitation.deliver("bar@example.com")
  end

  it "passes when a count constraint is satisfied" do
    # 透過 have_recieved 看方法有沒有被呼叫
    expect(invitation).to have_received(:deliver).twice
  end

  it "passes when an order constraint is satisifed" do
  # 加上 with 檢查方法呼叫時傳入的參數
  # 加上 ordered 代表要按照此順序呼叫
    expect(invitation).to have_received(:deliver).with("foo@example.com").ordered
    expect(invitation).to have_received(:deliver).with("bar@example.com").ordered
  end
end

HTTP Request & API Server

Ruby 並沒有提供 http server 的封裝,只有 tcp server,所以我們選用 Rack 這一套 library,他被 Ruby 生態圈廣泛採用的底層框架,如 RoR 也是

Rack provides a minimal, modular, and adaptable interface for developing web applications in Ruby.

API Server

新增 server.ru,透過 $rackup server.ru 啟動

require 'rack'
require 'json'

rack_proc = lambda { |env|
  req = Rack::Request.new(env)

  case req.request_method + ":" + req.path_info
  when "GET:/hello"
    return [200, { "content-type"=> "application/json" }, [ { "hello" => req.params["name"] }.to_json ]]
  when "POST:/world"
    body = JSON.parse req.body.gets
    return [200, { "content-type"=> "application/json" }, [ { "hello" => body["name"]}.to_json ]]
  else
    return [406, { "content-type"=> "html/txt" }, ['not_implemented_yet']]
  end
}
run rack_proc

HTTP Request

參考 5 ways to make HTTP requests in Ruby

require "http"

response = HTTP.get("http://localhost:9292/hello", :params => {:name => "world"})
p response.parse

response = HTTP.post("http://localhost:9292/world", :body => {:name => "jojo" }.to_json)
p response.parse

結語

寫過最長的文章,整理了這一個禮拜上手 Ruby 的過程,Ruby 實際上還有非常多的 黑魔法,跟新同事們一起上手的過程不斷發現驚喜(恐?!)
必須坦白說上手到現在沒有很愛 Ruby,因為太自由了,又有太多關鍵字跟寫法,可能要在一段時間熟悉,之後再來慢慢補充