這篇文章是閱讀『Refactoring: Ruby Edition』這本書的學習筆記,會記錄在書中看到的觀念,以及書中提供的重構範例。
影片出租店的客戶請你做一隻程式:
『在影片出租店中,顧客可以選擇不同的影片、不同的租期,而商家會依照影片的租期來計算費用。影片類型分為三種:一般片、兒童片、新片。程式要能夠計算費用、並輸出收據,收據上會有收費明細與顧客的點數,點數計算方法會依據影片是否為新片而有所不同。』
起始例子:
Movie | Rental | Customer |
---|---|---|
price_code | days_rented | |
statement() |
Movie
class Movie
REGULAR = 0
NEW_RELEASE = 1
CHILDREN = 2
attr_reader :title
attr_accessor :price_code
def initialize(title, price_code)
@title, @price_code = title, price_code
end
end
Rental
class Rental
attr_reader :movie, :days_rented
def initialize(movie, days_rented)
@movie, @days_rented = movie, days_rented
end
end
Customer
class Customer
attr_reader :name
def initialize(name)
@name = name
@rentals = []
end
def add_rental(arg)
@rentals << arg
end
def statement
total_amount, frequent_renter_points = 0, 0
result = "Rental Record for #{@name}\n"
@rentals.each do |element|
this_amount = 0
# determine amounts for each line
case element.movie.price_code
when Movie::REGULAR
this_amount += 2
this_amount += (element.days_rented - 2) * 1.5 if element.days_rented > 2
when Movie::NEW_RELEASE
this_amount += element.days_rented * 3
when Movie::CHILDREN
this_amount += 1.5
this_amount += (element.days_rented - 3) * 1.5 if element.days_rented > 3
end
# add frequent renter points
frequent_renter_points += 1
# add bonus for a two day new release rental
if element.movie.price_code == Movie::NEW_RELEASE && element.days_rented > 1
frequent_renter_points += 1
end
#show figures for this rental
result += element.movie.title + "\t" + this_amount.to_s + "\n"
total_amount += this_amount
end
# add footer lines
result += "Amount owed is #{total_amount}\n"
result += "You earned #{frequent_renter_points} frequent renter points"
result
end
end
對以上程式碼的評語
小提示:當你需要向一段程式裡添加功能,而程式的結構又不便於動手時,首要之務,就是重構程式碼來降低添加功能的難度,然後再加入需要的功能。
require 'minitest/autorun'
require_relative 'ch1'
describe Customer, "#statement" do
it "generate statement" do
movie = Movie.new("Star War", Movie::NEW_RELEASE)
rental = Rental.new(movie, 3)
customer = Customer.new("John Doe")
customer.add_rental(rental)
customer.statement.must_equal <<-EOS.gsub(/^\s+/, '').gsub(/\n$/,'')
Rental Record for John Doe
Star War\t9
Amount owed is 9
You earned 2 frequent renter points
EOS
end
end
小提示:進行重構之前,要先準備好一段可靠的測試。一步一步地進行修改與測試,犯錯時才好發現bug的位置。
目的:將太長的方法分解成小片段,並移到更適合它們的類別中
def statement
total_amount, frequent_renter_points = 0, 0
result = "Rental Record for #{@name}\n"
@rentals.each do |element|
this_amount = amount_for(element) #這行改變了!
# add frequent renter points
frequent_renter_points += 1
# add bonus for a two day new release rental
if element.movie.price_code == Movie::NEW_RELEASE && element.days_rented > 1
frequent_renter_points += 1
end
#show figures for this rental
result += element.movie.title + "\t" + this_amount.to_s + "\n"
total_amount += this_amount
end
# add footer lines
result += "Amount owed is #{total_amount}\n"
result += "You earned #{frequent_renter_points} frequent renter points"
result
end
def amount_for(element)
this_amount = 0
case element.movie.price_code
when Movie::REGULAR
this_amount += 2
this_amount += (element.days_rented - 2) * 1.5 if element.days_rented > 2
when Movie::NEW_RELEASE
this_amount += element.days_rented * 3
when Movie::CHILDREN
this_amount += 1.5
this_amount += (element.days_rented - 3) * 1.5 if element.days_rented > 3
end
this_amount
end
def amount_for(rental)
result = 0
case element.movie.price_code
when Movie::REGULAR
result += 2
result += (element.days_rented - 2) * 1.5 if element.days_rented > 2
when Movie::NEW_RELEASE
result += element.days_rented * 3
when Movie::CHILDREN
result += 1.5
result += (element.days_rented - 3) * 1.5 if element.days_rented > 3
end
result
end
小提示:傻瓜寫的程式碼只有電腦才能理解,優秀的工程師寫的程式碼可以讓其他人都能看懂。
class Rental
def charge
result = 0
case movie.price_code
when Movie::REGULAR
result += 2
result += (days_rented - 2) * 1.5 if days_rented > 2
when Movie::NEW_RELEASE
result += days_rented * 3
when Movie::CHILDREN
result += 1.5
result += (days_rented - 3) * 1.5 if days_rented > 3
end
result
end
end
class Customer
def statement
...
@rentals.each do |element|
this_amount = element.charge
...
end
end
目前程式狀態:
Movie | Rental | Customer |
---|---|---|
price_code | days_rented | |
charge() | statement() |
def statement
total_amount, frequent_renter_points = 0, 0
result = "Rental Record for #{@name}\n"
@rentals.each do |element|
this_amount = element.charge
# add frequent renter points
frequent_renter_points += 1
# add bonus for a two day new release rental
if element.movie.price_code == Movie::NEW_RELEASE && element.days_rented > 1
frequent_renter_points += 1
end
#show figures for this rental
result += element.movie.title + "\t" + this_amount.to_s + "\n"
total_amount += this_amount
end
# add footer lines
result += "Amount owed is #{total_amount}\n"
result += "You earned #{frequent_renter_points} frequent renter points"
result
end
def statement
total_amount, frequent_renter_points = 0, 0
result = "Rental Record for #{@name}\n"
@rentals.each do |element|
# add frequent renter points
frequent_renter_points += 1
# add bonus for a two day new release rental
if element.movie.price_code == Movie::NEW_RELEASE && element.days_rented > 1
frequent_renter_points += 1
end
#show figures for this rental #下面這兩行改變了~~
result += element.movie.title + "\t" + element.charge.to_s + "\n"
total_amount += element.charge
end
# add footer lines
result += "Amount owed is #{total_amount}\n"
result += "You earned #{frequent_renter_points} frequent renter points"
result
end
def statement
...
@rentals.each do |element|
# add frequent renter points
frequent_renter_points += 1
# add bonus for a two day new release rental
if element.movie.price_code == Movie::NEW_RELEASE && element.days_rented > 1
frequent_renter_points += 1
end
...
end
...
end
class Customer
def statement
total_amount, frequent_renter_points = 0, 0
result = "Rental Record for #{@name}\n"
@rentals.each do |element|
frequent_renter_points += element.frequent_renter_points #改在這裡!!
#show figures for this rental
result += element.movie.title + "\t" + element.charge.to_s + "\n"
total_amount += element.charge
end
# add footer lines
result += "Amount owed is #{total_amount}\n"
result += "You earned #{frequent_renter_points} frequent renter points"
result
end
end
class Rental
def frequent_renter_points
(movie.price_code == Movie.NEW_RELEASE && days_rented > 1) ? 2 : 1
end
end
目前程式狀態:
Movie | Rental | Customer |
---|---|---|
price_code | days_rented | |
charge() | statement() | |
frequent_renter_points() |
class Customer
def statement
frequent_renter_points = 0 #先拿掉total_amount
result = "Rental Record for #{@name}\n"
@rentals.each do |element|
frequent_renter_points += element.frequent_renter_points
#show figures for this rental
result += element.movie.title + "\t" + element.charge.to_s + "\n"
end
# add footer lines
result += "Amount owed is #{total_charge}\n" #這裡也改了!
result += "You earned #{frequent_renter_points} frequent renter points"
result
end
private
def total_charge
result = 0
@rentals.each do |element|
result += element.charge
end
result
end
end
def total_charge
@rentals.inject(0) { |sum, rental| sum + rental.charge }
end
class Customer
def statement
result = "Rental Record for #{@name}\n"
@rentals.each do |element|
#show figures for this rental
result += element.movie.title + "\t" + element.charge.to_s + "\n"
end
# add footer lines
result += "Amount owed is #{total_charge}\n"
result += "You earned #{total_frequent_renter_points} frequent renter points"
#上面那行改了!
result
end
private
def total_frequent_renter_points
@rentals.inject(0){ |sum, rental| sum + rental.frequent_renter_points }
end
end
目前程式狀態:
Movie | Rental | Customer |
---|---|---|
price_code | days_rented | |
charge() | statement() | |
frequent_renter_points() | total_charge() | |
total_frequent_renter_points() |
接下來,暫時放下重構,加入html_statement方法
class Customer
def html_statement
result = "<h1>Rentals for <em>#{@name}</em></h1><p>\n"
@rentals.each do |element|
# show figures for this rental
result += element.movie.title + ": " + element.charge.to_s + "<br>\n"
end
#add footer lines
result += "<p>You owe <em>#{total_charge}</em></p>\n"
result += "On this rental you earned " +
"<em>#{total_frequent_renter_points}</em> " +
"frequent renter points</p>"
result
end
end
class Rental
def charge
result = 0
case movie.price_code
when Movie::REGULAR
result += 2
result += (days_rented - 2) * 1.5 if days_rented > 2
when Movie::NEW_RELEASE
result += days_rented * 3
when Movie::CHILDREN
result += 1.5
result += (days_rented - 3) * 1.5 if days_rented > 3
end
result
end
end
class Rental
def charge
movie.charge(days_rented)
end
end
class Movie
def charge(days_rented)
result = 0
case price_code
when REGULAR
result += 2
result += (days_rented - 2) * 1.5 if days_rented > 2
when NEW_RELEASE
result += days_rented * 3
when CHILDREN
result += 1.5
result += (days_rented - 3) * 1.5 if days_rented > 3
end
result
end
end
class Rental
def frequent_renter_points
(movie.price_code == Movie.NEW_RELEASE && days_rented > 1) ? 2 : 1
end
end
class Rental
def frequent_renter_points
movie.frequent_renter_points(days_rented)
end
end
class Movie
def frequent_renter_points(days_rented)
(price_code == NEW_RELEASE && days_rented > 1) ? 2 : 1
end
end
目前程式狀態:
Movie | Rental | Customer |
---|---|---|
price_code | days_rented | |
charge(days_rented) | charge() | statement() |
frequent_renter_points(days_rented) | frequent_renter_points() | total_charge() |
total_frequent_renter_points() | ||
html_statement() |
最後…要來處理繼承!
class Movie
REGULAR = 0
NEW_RELEASE = 1
CHILDREN = 2
attr_reader :title
attr_accessor :price_code
def initialize(title, price_code)
@title, @price_code = title, price_code
end
end
class Movie
...
attr_reader :price_code #改成getter而已
def price_code= (value) #自己定義了setter
@price_code = value
end
def initialize(title, the_price_code) #這裡也有改
@title, self.price_code = title, the_price_code
end
end
class RegularPrice
end
class NewReleasePrice
end
class ChildrensPrice
end
def price_code= (value)
@price_code = value
@price = case price_code
when REGULAR : RegularPrice.new
when NEW_RELEASE : NewReleasePrice.new
when CHILDRENS : ChildrensPrice.new
end
end
class Movie
def charge(days_rented)
result = 0
case price_code
when REGULAR
result += 2
result += (days_rented - 2) * 1.5 if days_rented > 2
when NEW_RELEASE
result += days_rented * 3
when CHILDREN
result += 1.5
result += (days_rented - 3) * 1.5 if days_rented > 3
end
result
end
end
class RegularPrice
def charge(days_rented)
result = 2
result += (days_rented - 2) * 1.5 if days_rented > 2
result
end
end
class NewReleasePrice
def charge(days_rented)
days_rented * 3
end
end
class ChildrensPrice
def charge(days_rented)
result = 1.5
result += (days_rented - 3) * 1.5 if days_rented > 3
result
end
end
class Movie
def charge(days_rented)
@price.charge(days_rented)
end
end
class Movie
def frequent_renter_points(days_rented)
(price_code == NEW_RELEASE && days_rented > 1) ? 2 : 1
end
end
module DefaultPrice
def frequent_renter_points(days_rented)
1
end
end
class RegularPrice
include DefaultPrice
...
end
class ChildrensPrice
include DefaultPrice
...
end
class NewReleasePrice
def frequent_renter_points(days_rented)
days_rented > 1 ? 2 : 1
end
end
class Movie
def frequent_renter_points(days_rented)
@price.frequent_renter_points(days_rented)
end
end
# calling code
movie = Movie.new("模仿遊戲", Movie::NEW_RELEASE)
# and later...
movie.price_code = Movie::REGULAR
class Movie
...
def price_code= (value)
@price_code = value
@price = case price_code
when REGULAR : RegularPrice.new
when NEW_RELEASE : NewReleasePrice.new
when CHILDRENS : ChildrensPrice.new
end
end
end
# calling code
movie = Movie.new("模仿遊戲", NewReleasePrice.new)
# and later...
movie.price = RegularPrice.new
class Movie
attr_writer :price
end
最後程式狀態:
Movie | Rental | Customer |
---|---|---|
title | days_rented | name |
charge(days_rented) | charge(days_rented) | statement() |
frequent_renter_points(days_rented) | frequent_renter_points | total_charge() |
total_frequent_renter_points() | ||
html_statement() |
New Release Price | Regular Price | Childrens Price | Default Price |
---|---|---|---|
charge(days_rented) | charge(days_rented) | charge(days_rented) | frequent_renter_points(days_rented) |
frequent_renter_points(days_rented) |
protocol |
---|
charge(days_rented) |
最終程式碼:
Movie
class Movie
attr_reader :title
attr_writer :price
def initialize(title, price)
@title, @price = title, price
end
def charge(days_rented)
@price.charge(days_rented)
end
def frequent_renter_points(days_rented)
@price.frequent_renter_points(days_rented)
end
end
Rental
class Rental
attr_reader :movie, :days_rented
def initialize(movie, days_rented)
@movie, @days_rented = movie, days_rented
end
def charge
movie.charge(days_rented)
end
def frequent_renter_points
movie.frequent_renter_points(days_rented)
end
end
Customer
class Customer
attr_reader :name
def initialize(name)
@name = name
@rentals = []
end
def add_rental(arg)
@rentals << arg
end
def statement
result = "Rental Record for #{@name}\n"
@rentals.each do |element|
#show figures for this rental
result += element.movie.title + "\t" + element.charge.to_s + "\n"
end
# add footer lines
result += "Amount owed is #{total_charge}\n"
result += "You earned #{total_frequent_renter_points} frequent renter points"
result
end
def html_statement
result = "<h1>Rentals for <em>#{@name}</em></h1><p>\n"
@rentals.each do |element|
# show figures for this rental
result += element.movie.title + ": " + element.charge.to_s + "<br>\n"
end
#add footer lines
result += "<p>You owe <em>#{total_charge}</em></p>\n"
result += "On this rental you earned " +
"<em>#{total_frequent_renter_points}</em> " +
"frequent renter points</p>"
result
end
private
def total_charge
@rentals.inject(0) { |sum, rental| sum + rental.charge }
end
def total_frequent_renter_points
@rentals.inject(0) { |sum, rental| sum + rental.frequent_renter_points }
end
end
Default Price
module DefaultPrice
def frequent_renter_points(days_rented)
1
end
end
Regular Price
class RegularPrice
include DefaultPrice
def charge(days_rented)
result = 2
result += (days_rented - 2) * 1.5 if days_rented > 2
result
end
end
Childrens Price
class ChildrensPrice
include DefaultPrice
def charge(days_rented)
result = 1.5
result += (days_rented - 3) * 1.5 if days_rented > 3
result
end
end
New Release Price
class NewReleasePrice
def charge(days_rented)
days_rented * 3
end
def frequent_renter_points(days_rented)
days_rented > 1 ? 2 : 1
end
end
最終程式碼的Test
require 'minitest/autorun'
require_relative 'ch1'
describe Customer, "#statement" do
it "generate statement" do
# movie = Movie.new("Star War", Movie::NEW_RELEASE) # 這行是原本的
movie = Movie.new("Star War", NewReleasePrice.new) # 改成這行
rental = Rental.new(movie, 3)
customer = Customer.new("John Doe")
customer.add_rental(rental)
customer.statement.must_equal <<-EOS.gsub(/^\s+/, '').gsub(/\n$/,'')
Rental Record for John Doe
Star War\t9
Amount owed is 9
You earned 2 frequent renter points
EOS
end
end