重構小技巧 in Ruby

本文內容主要參閱 Refactoring: Ruby Edition: Ruby Edition (Addison-Wesley Professional Ruby Series)

什麼是重構?

重構的過程不應改變程式碼原有的行為,重構時只應以下列三點為考量:

  1. 讓程式碼更好被閱讀。
  2. 使程式邏輯更清晰。
  3. 使程式更容易被修改。

最終提升程式得以持續開發的速度,此亦為重構的主要目的。

為何需要重構?

  • 程式碼閱讀性不佳,每次修改時都需要重新理解(通靈)原作者的思維(天書),花費額外的時間成本。
  • 相同的程式邏輯散落地出現在多處不同的地方時,修改時要再三確認是否每一段相同但在不同地方的程式碼都改到了。
  • 需要為現有的程式碼加入新的行為或修正,但突然發現加不進去或者不知道該怎麼加。
  • 程式碼包含複雜的邏輯與條件,使得新的修改造成更多的問題或錯誤的結果。

重構的最佳時機

當你要對現有的程式碼加入一個新的 Function 或者要修正一個 Bug 時,但你發現原有的程式碼難以閱讀,因此難以修改時,此為最佳的重構時機。

重構的基本流程

  1. 確認單元測試的完整性,沒有測試的程式碼不應輕易重構。
  2. 重構原有的程式碼符合原有的行為及邏輯。
  3. 然後加入新的 Function 或進行 Bug Fix 。

何時你不該重構?

  • 當你準備放棄原有的程式碼,決定完全重寫時:可能原本的程式就是一個錯誤(或者充滿太多錯誤時),沒有重構的價值。值得重構的程式碼應該是大部份的功能與邏輯都正確,只是樣子醜了點罷了。
  • 沒有測試的程式碼,不應重構,應該先補上程式行為 (behavior) 的測試,再開始重構。
  • 死線 (dealine) 在即,因為你沒有時間了,重構是程式設計師間的浪漫,非程式設計師是很難(可說是無法)理解的。

Creating a beautiful code base should always be a priority; however, creating working software’s the number one priority

  • 單純為了重構而重構 。 > The most costly refacoring is refactoring for academic purposes.

If you can’t prove to the business and the project manager that a refactoring is worth-doing, you might be refactoring for academic purposes refactoring.

重構可由下列四個面向來執行

  1. 整理共享的邏輯 (To enable sharing of logic)。
  2. 將目的與實作細節分開來解釋,特別是在命名方法名稱的時候 (To explain intention and implementation separately)。
  3. 隔離容易變動的程式碼,最簡單且基礎的方式就是把易變動的部份關在私有方法中,限制被存取的範圍 (To isolate change)。
  4. 將複雜的條件 (conditionals) 轉化為簡明的訊息(messages)傳遞 (To encode conditional logic)。

本文僅以重複的程式碼 (Duplicated Code) 為探討主題

Duplicated Code 大概有下列三種情況:

  1. 相同的程式邏輯出現在多個方法間。
  2. 子類別間重複的程式碼。
  3. 不相干類別間重複的程式碼。

1. 相同的程式邏輯出現在多個方法中時

  • 可採用 Extract Method 技巧,把每個重複的程式段落,轉化為一個個的小方法,方法名稱必須要明確指定方法的目的,理想的方法名稱,是光看方法名稱就知道該方法要做什麼。
#
# Extract Method
# 
# 重構前
#
def flight_tickets
  info = []
  info << outbound_flight_info
  info << return_flight_info
  info << total_fees
  info.join("\n")
end

def outbound_flight_info
  info = []
  info << "Outbound"
  info << "From: Taipei(TPE)"
  info << "To: Tokyo(NRT)"
  info << "Flight: IT200"
  info << "Airline: Tigerair Taiwan"
  info << "Date: Tue, Nov 28, 2017"
  info << "Departure time: 06:35 AM"
  info << "Arrival time: 10:30 AM"
  info << "Journey duration: 2h 55m"
  info << "Terminal: T1"
  info << "Non-stop"
  info << "Basic economy"
  info << "Fare: TWD 7,998"
  info << "Fees & Taxes: TWD 1,000"
  info << "2 adults"
end

def return_flight_info
  info = []
  info << "Return"
  info << "From: Tokyo(NRT)"
  info << "To: Taipei(TPE)"
  info << "Flight: IT201"
  info << "Airline: Tigerair Taiwan"
  info << "Date: Fri, Dec 1, 2017"
  info << "Departure time: 01:00 PM"
  info << "Arrival time: 04:20 PM"
  info << "Journey duration: 4h 20m"
  info << "Terminal: T2"
  info << "Non-stop"
  info << "Basic economy"
  info << "Fare: TWD 3,398"
  info << "Fees & Taxes: TWD 1,378"
  info << "2 adults"
end

def total_fees
  "Total: TWD 13,774"
end

puts flight_tickets
#
# Extract Method
# 
# 重構後
# 這邊初步的重構重點只是單純的把共享的邏輯整理成一個個簡單的私有(private)方法
# 增加程式的閱讀性
#
require 'date'
require 'time'

def flight_tickets
  info = []
  info << outbound_flight_info
  info << return_flight_info
  info << total_fees(13_774)
  info.join("\n")
end

def outbound_flight_info
  info = []
  info << "Outbound"
  info << from("Taipei", "TPE")
  info << to("Tokyo", "NRT")
  info << flight_number("IT200")
  info << airline("Tigerair Taiwan")
  info << flight_date(2017, 11, 28)
  info << departure_time(6, 35)
  info << arrival_time(10, 30)
  info << journey_duration(2, 55)
  info << terminal(1)
  info << non_stop
  info << fare_class
  info << fare(7_998)
  info << fees_and_taxs(1_000)
  info << adult_travelers(2)
end

def return_flight_info
  info = []
  info << "Return"
  info << from("Tokyo", "NRT")
  info << to("Taipei", "TPE")
  info << flight_number("IT201")
  info << airline("Tigerair Taiwan")
  info << flight_date(2017, 12, 1)
  info << departure_time(13, 00)
  info << arrival_time(16, 20)
  info << journey_duration(4, 20)
  info << terminal(2)
  info << non_stop
  info << fare_class
  info << fare(3_398)
  info << fees_and_taxs(1_378)
  info << adult_travelers(2)
end

private

def from(city, airport_code)
  "From: #{city}(#{airport_code})"
end

def to(city, airport_code)
  "To: #{city}(#{airport_code})"
end

def total_fees(amount)
  "Total: TWD #{number_delimiter(amount)}"
end

def flight_number(number)
  "Flight: #{number}"
end

def airline(airline_name)
  "Airline: #{airline_name}"
end

def flight_date(year, month, day)
  "Date: #{Date.new(year, month, day).strftime('%a, %b %-d, %Y')}"
end

def departure_time(hour, minute)
  "Departure time: #{time_formater(hour, minute)}"
end

def arrival_time(hour, minute)
  "Arrival time: #{time_formater(hour, minute)}"
end

def journey_duration(hours, minutes)
  "Journey duration: #{hours}h #{minutes}m"
end

def terminal(number)
  "Terminal: T#{number}"
end

def non_stop
  "Non-stop"
end

def fare_class
  "Basic economy"
end

def fare(amount)
  "Fare: TWD #{number_delimiter(amount)}"
end

def fees_and_taxs(amount)
  "Fees & Taxes: TWD #{number_delimiter(amount)}"
end

def adult_travelers(amount)
  "#{amount} adult" << (amount > 1 ? 's' : '')
end

def time_formater(hour, minute)
  Time.parse("#{hour}:#{minute}").strftime('%I:%M %p')
end

def number_delimiter(number)
  parts = number.to_s.split('.')
  parts[0].gsub!(/(\d)(?=(\d\d\d)+(?!\d))/, "\\1,")
  parts.join('.')
end

puts flight_tickets

2. 子類別間重複的程式碼

  • 完全相同的程式邏輯
    • 使用上述 Extract Method 技巧。
    • Pull Up Method 技巧:把分佈在子類別間,但執行結果完全相同的方法,往上層 (superclass) 類別移動。
  • 相似但不完全相同的程式邏輯
    • 對相同的部份進行 Extract Method 。
    • 對不同的部份改寫成 Form Template Method,在上層定義相同的方法名稱,使其接受一致的參數規格 (signature),但實作的內容不同,把實作的細節(步驟)保留給各子類中去各自去處理,可以有下列兩種方式來實作:
      • 傳統的繼承式樣版方法 (Template Method Using Inheritance)
      • 擴充模組式的樣版方法 (Template Method Using Extension of Modules):可避免因繼承所造成的複雜度

下列用簡易版的機票價格做為解釋範例

# 
# 傳統的繼承式樣版方法
# Template Method Using Inheritance
# 
class Ticket
  def price
    (fare + add_on_fees) * tax_rate
  end
end

class FirstClassTicket < Ticket
  def fare
    1000
  end

  def add_on_fees
    200
  end

  def tax_rate
    1.5
  end
end

class BusinessClassTicket < Ticket
  def fare
    500
  end

  def add_on_fees
    100
  end

  def tax_rate
    1.33
  end
end

class EconomyClassTickets < Ticket
  def fare
    100
  end

  def add_on_fees
    20
  end

  def tax_rate
    1.1
  end
end

# 需要新增超級早島票的票種時(不改變方法名稱)

class SuperEarilyBirdTicket
  def price
    (fare + add_on_fees) * 0.5 * tax_rate
  end
end

class SuperEarilyBirdFirstClassTicket < SuperEarilyBirdTicket
  def fare
    1000
  end

  def add_on_fees
    200
  end

  def tax_rate
    1.5
  end
end

class SuperEarilyBirdBusinessClassTicket < SuperEarilyBirdTicket
  def fare
    500
  end

  def add_on_fees
    100
  end

  def tax_rate
    1.33
  end
end

class SuperEarilyBirdEconomyClassTickets < SuperEarilyBirdTicket
  def fare
    100
  end

  def add_on_fees
    20
  end

  def tax_rate
    1.1
  end
end

puts FirstClassTicket.new.price
puts BusinessClassTicket.new.price
puts EconomyClassTickets.new.price
puts SuperEarilyBirdFirstClassTicket.new.price
puts SuperEarilyBirdBusinessClassTicket.new.price
puts SuperEarilyBirdEconomyClassTickets.new.price
# 
# 擴充模組式的樣版方法
# Template Method Using Extension of Modules
# 
class Ticket  
  def price
    (fare + add_on_fees) * tax_rate
  end
end

module FirstClassTicket
  def fare
    1000
  end

  def add_on_fees
    200
  end

  def tax_rate
    1.5
  end
end

module BusinessClassTicket
  def fare
    500
  end

  def add_on_fees
    100
  end

  def tax_rate
    1.33
  end
end

module EconomyClassTickets
  def fare
    100
  end

  def add_on_fees
    20
  end

  def tax_rate
    1.1
  end
end

class TicketFare
  def first_class
    Ticket.new.extend(FirstClassTicket).price
  end

  def business_class
    Ticket.new.extend(BusinessClassTicket).price
  end

  def economy_class
    Ticket.new.extend(EconomyClassTickets).price
  end

  def super_earily_bird_first_class
    SuperEarilyBirdTicket.new.extend(FirstClassTicket).price
  end

  def super_earily_bird_business_class
    SuperEarilyBirdTicket.new.extend(BusinessClassTicket).price
  end

  def super_earily_bird_economy_class
    SuperEarilyBirdTicket.new.extend(EconomyClassTickets).price
  end
end

# 需要新增超級早島票的票種時

class SuperEarilyBirdTicket
   def price
    (fare + add_on_fees) * 0.5 * tax_rate
  end
end

puts TicketFare.new.first_class
puts TicketFare.new.business_class
puts TicketFare.new.economy_class
puts TicketFare.new.super_earily_bird_first_class
puts TicketFare.new.super_earily_bird_business_class
puts TicketFare.new.super_earily_bird_economy_class
  • 方法內的程式碼,可以使用不同(更佳)的演算法來實作時
    • Substitute Algorithm:簡單說就是改良原有的程式碼,以更好的方式來實作,讓程式碼更乾淨。
    • 對出現在方法內的相同部份做 Extract Surrounding Method:假設有兩個方法的實作幾乎一樣,只有中間的一小部份不相同時,可把不同的部份重構為可接受 block 的方法。
#
# Substitute Algorithm
#
# 重構前
# 
def booking_code(code)
  if code == 'F'
    'First class'
  elsif code == 'C'
    'Business class'
  elsif code == 'Y'
    'Economy class'
  else
    raise "Booking code is incorrect"
  end
end
#
# Substitute Algorithm
#
# 重構後
# 
CODES = {
  "F" => "First class",
  "C" => "Business class",
  "Y" => "Economy class"
}

def booking_code(code)
  CODES.fetch(code)
rescue
  raise "Booking code is incorrect"
end

puts booking_code("F")
# 
# Extract Surrounding Method
# 
# 重構前
# 
def first_class_services
  puts "\n"
  puts "-" * 25
  puts __method__.upcase
  puts "-" * 25
  puts "Fine Dinning options"
  puts "In-Seat Power: power outlet"
  puts "Personalize Service"
  puts "Complimentary Non-Alcoholic Beverages"
  puts "Wi-Fi for Purchase"
  puts "In-Seat Power: USB PORT"
  puts "\n"
end

def business_class_service
  puts "\n"
  puts "-" * 25
  puts __method__.upcase
  puts "-" * 25
  puts "Premium Snacks"
  puts "In-Seat Power: power outlet"
  puts "Complimentary Non-Alcoholic Beverages"
  puts "Wi-Fi for Purchase"
  puts "In-Seat Power: USB PORT"
  puts "\n"
end

def economy_class_services
  puts "\n"
  puts "-" * 25
  puts __method__.upcase
  puts "-" * 25
  puts "Snacks, Meals"
  puts "Complimentary Non-Alcoholic Beverages"
  puts "Wi-Fi for Purchase"
  puts "In-Seat Power: USB PORT"
  puts "\n"
end

first_class_services
business_class_service
economy_class_services

# 機上服務參考資料: https://www.delta.com/content/www/en_US/traveling-with-us/onboard-experience/first-class.html
# 
# Extract Surrounding Method
# 
# 重構後
# 
def first_class_services
  onboard_experience do
    puts "Fine Dinning options"
    puts "In-Seat Power: power outlet"
    puts "Personalize Service"
  end
end

def business_class_service
  onboard_experience do
    puts "Premium Snacks"
    puts "In-Seat Power: power outlet"
  end
end

def economy_class_services
  onboard_experience do
    puts "Snacks, Meals"
  end
end

def onboard_experience
  puts "\n"
  puts "-" * 25
  puts caller_locations(1,1)[0].label.upcase
  puts "-" * 25
  yield
  puts "Complimentary Non-Alcoholic Beverages"
  puts "Wi-Fi for Purchase"
  puts "In-Seat Power: USB PORT"
ensure
  puts "\n"
end

first_class_services
business_class_service
economy_class_services

3. 不相干類別間重複的程式碼

  • Extract Class:將放在一起顯得很合理的部份,抽取出來成為一個獨立的類別。
  • Extract Module:把相同行為的方法行為,從不同的類別中移到一個新的模組中 (Module),再讓這些類別去引入 (Include)。

結語

本文直接使用程式碼解釋重構的流程,是因為許多重構的技巧其實透過程式碼本身來解釋最為直接,若需要更詳盡的解釋,推薦可直接閱讀原作,原作中有大量的程式範例與重構步驟可供參考,除了本文介紹到的 Duplicated Code 之重構技巧外,還有其他 24 個 Bad Smells in Code 的重構因應之道。

  • Long Method
  • Large Class
  • Long Parameter List
  • Divergent Change
  • Shotgun Surgery
  • Feature Envy
  • Data Clumps
  • Primitive Obsession
  • Case Statements
  • Parallel Inheritance Hierarchie
  • Lazy Class
  • Speculative Generality
  • Temporary Field
  • Message Chains
  • Middle Man
  • Inappropriate Intimacy
  • Alternative Classes with Different Interfaces
  • Incomplete Library Class
  • Data Class
  • Refused Bequest
  • Comments
  • Metaprogramming Madness
  • Disjointed API
  • Repetitive Boilerplate