Link Search Menu Expand Document

15. 什麽是物件導向?

所謂的物件(Object)就是指一個帶有狀態和方法的容器,例如以下是 JavaScript 中的自定義物件:

var my_object = {
  name: "ihower",
  move: function(){
    console.log( this.name + " is moving" )
  }
}


my_object.move()

my_object 有兩個屬性,一個是 name 一個是 move,而 move 其實是一個匿名函式。這個物件有自己的狀態(name 是 ihower),有自己的方法(move)。我們把相關的數據和方法,一起包進物件裡面。

my_object.move() 可以看成是朝物件 my_object 發送一個 move 訊息。接收者 my_object 接到一個 move 訊息。

物件導向(Object-Oriented)程式設計指的就是以物件為基礎的編程方式,整個軟體就是一群物件之間的互動。

為什麽要用物件導向?

在上一章節中,算法和資料結構強調的是正確性和高效率。但是在物件導向要追求的是軟體的擴充性、維護性、修改彈性、可讀性、可測性,是一種將代碼適當安排組織的一種設計方式。

Ruby 是一個非常物件導向思維的程式語言,在 Ruby 中其實每一個數據其實都是物件。

Class-based 基於類別的物件導向設計

主流程式語言中,只有 JavaScript 可以像剛剛一樣直接創造一個自定義物件,其他程式語言包括 Ruby,都是要先定義類別(Class-based)才能創造物件。所謂的類別(Class)就是去定義了某一種類型的物件所擁有的屬性和方法。類別是抽象的事物,而不是其所描述的特定物件。以下的 Ruby 代碼先定義了 Person 類,然後再用 new 方法創造出兩個物件 p1p2

class Person
  attr_accessor :name

  def move
    puts "Hello, #{@name}"
  end
end

p1 = Person.new
p1.name = "ihower"
p1.move # 輸出 Hello, ihower

p2 = Person.new
p2.name = "John"
p2.move # 輸出 Hello, John

你可以想像類別(Class)就是物件(Object)的模版,這些物件都有 name 屬性和一個 move 方法。

名詞釋疑:除了 Object (物件)的講法,這些由類產生出來的 Object,又叫做 Instance (實例)

16. Ruby 語法說明

讓我們完整解說一下 Ruby 中類別的語法:

class Person

    def initialize(name)
        @name = name
    end

    def say(word)
        puts "#{word}, #{@name}"
    end

end

p1 = Person.new("ihower")
p2 = Person.new("ihover")

p1.say("Hello") # 輸出 Hello, ihower
p2.say("Hello") # 輸出 Hello, ihover

  • 類別一定是大寫開頭,也是一種常數
  • initialize 是物件的建構方法,當調用 new 的時候的會把參數傳進這裡
  • @ 開頭的變數,也就是範例中的 @name 叫做物件變數(instance variable)。這個是物件的內部狀態。
  • def 會定義物件的方法
class Person

    @@name = ihower

    def self.say
        puts @@name
    end

end

Person.say # 輸出 ihower

  • 兩個 @@ 開頭的變數,也就是 @@name 叫做類別變數(class variable),這個是屬於類別的
  • self.def 開頭定義的方法,也就是 def self.say 叫做類別方法。用 Person.say 就會調用。

另外,物件變數(@開頭)和類變數(@@開頭),都是封裝在類別內部,類別外無法存取。都需透過定義方法才可以存取到。

例如:

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

p = Person.new('ihower')
p.name
# NoMethodError 會出錯
p.name='peny'
# NoMethodError 會出錯

需要定義存取的方法,一個讀、一個寫:

class Person

   def initialize(name)
    @name = name
   end

   def name
     @name
   end

   def name=(name)
     @name = name
   end

end

p = Person.new('ihower')
p.name
=> "ihower"
p.name = "peny"
=> "peny"

attr_* 用法

由於定義存取方法很常見,Ruby 提供了 attr_accessorattr_readerattr_writer 等類別方法幫我們定義:

class Person

  attr_accessor :name

  attr_reader: foo
  attr_writer: bar
end

其中 attr_accessor :name 等同於剛剛我們自定義的存取方法。attr_reader 只定義讀、attr_writer 只定義寫。

public/protected/private 方法

物件方法還有分 public(公開)、protected 和 private 不同,預設是 public。

class MyClass

    def public_method
      private_method # 調用 private 方法
      self.protected_method # 調用 protected_method 方法,self. 可加可不加
    end

    private # 以下定義的都是 private 方法

    def private_method
    end

    protected  # 以下定義的都是 protected 方法

    def protected_method
    end

end

m = MyClass.new
m.public_method

m.private_method
# NoMethodError: private method `private_method' called 會出錯

m.protected_method
# NoMethodError: protected method `protected_method' called 會出錯

你不能對物件 m 調用 privated_method 或 protected_method。這兩個方法只能在內部調用。

那 private 和 protected 有什麽差別? protected 允許調用同一類的物件。private 則嚴格限制在內部調用。

為什麽物件導向語言會設計這個功能呢?這是因為我們希望管控有哪些 public 方法。這些 public 是公開的 API 會給程序員調用的,如果有任何修改都會影響到軟體的其他地方要一起修改。但是 private 但 protected 方法就只會影響這個物件的內部而已。

17. 特性一: 封裝(encapsulation)

物件導向的其中一個特色就是封裝,調用者不需要關心內部結構,只需根據公開接口進行操作。程式只依賴物件的公開接口,而不依賴內部結構。這樣內部的結構可以根據架構需求而修改,而不會影響到其他程式。

我們看一個沒有封裝的例子,分數相加:

# 設計處理分數的相加,假設分子是 x、分母是 y

def add_rational_numerator(x1, y1, x2, y2)
    x1*y2 + x2*y1
end

def add_rational_denominator(x1, y1, x2, y2)
    y1*y2
end

# 2/3 + 3/4
x1 = 2
y1 = 3
x2 = 3
y2 = 4

answer_x = add_rational_numerator(x1, y1, x2, y2)
answer_y = add_rational_denominator(x1, y1, x2, y2)

puts "#{answer_x}/#{answer_y}"

分子、分母、算分母的方法、算分子的方法,都是分開的。

如果改用物件導向來寫,首先定義分數的類:

class MyRational

  attr_accessor :x, :y

  def initialize(x, y)
    @x, @y = x, y
  end

  def add(target)
    MyRational.new(@x*target.y + @y*target.x, @y*target.y)
  end

end

# 2/3 + 3/4
a = MyRational.new(2,3)
b = MyRational.new(3,4)
c = a.add(b)

puts "#{c.x}/#{c.y}"

於是有關分數的數據和方法,都被一起封裝到物件裡面去了。這樣就很清楚它們是相關的。

18. 特性二: 繼承(inheritance)

繼承可以讓父類的定義都複製到子類,在 Ruby 中用 class Child < Parent 符號表示:

class Vehicle

  def move
    puts "vehicle move"
  end

end

class Car < Vehicle

  def foo
    puts "car foo"
  end

end

class Bike < Vehicle

  # overwrite
  def move
    puts "special bike move"
  end

  def foo
    puts "bike foo"
  end

end

car = Car.new
bike = Bike.new

car.move()
bike.move()

car.foo()
bike.foo()

Vehicle 類定義了 move方法,而 CarBike 都繼承自 Vehicle,因此他們也都有 move 方法。但是在 Bike 中你也可以復寫掉 move 方法。

在 Rails 中也很常見繼承,打開任一個 Model 檔案,都是繼承自 ApplicationRecord。再打開會發現 ApplicationRecord 繼承自 ActiveRecord::Base。後者是 Rails 框架的核心類,我們之所以可以調用 .save.find.where 等等方法就是在 ActiveRecord::Base 中定義的。。

打開任一個 Controller 檔案,都是繼承自 ApplicationController,然後 ApplicationController 又是繼承自 ActionController::Base,我們之所以可以調用 before_actionrenderredirect_to 等等方法就是在 ActionController::Base 中定義的。

透過繼承,我們寫的 Model 和 Controller 都是基於 Rails 已經寫好的功能進行擴充而已。

多重繼承

上述的寫法只能單一繼承,也就是只能有一個父類。如果有多個父類要繼承,在 Ruby 中會用到 module:

module Ownership
  def show_owner
    puts "#{self.class} show_owner called"
  end
end

class Vehicle
  def move
    puts "move"
  end
end

# Car has two parents: Car and Ownership
class Car < Vehicle
  include Ownership
end

class House
  include Ownership
end

car = Car.new
house = House.new

car.show_owner()
house.show_owner()

其中 module Ownership 會用 include 的語法 mix-in(混入)到 Car 裡面。

那 class 跟 module 有什麽不一樣?你不能實例化 module,也就是不能對 module 調用 new 方法來產生物件。

module 命名空間

順道一提 module 的另一個用途,就是拿來做命名空間,也就是讓常數的命名長一點避免撞名:

module  A
  class B
  end
end

這個被 module A 包起來的 class B,如果要使用它要用 A::B

如果 module A 已經定義過了,則可以這樣寫:

class A::C

end

假如在 module A 裡面定義了一個跟最外層撞名的類別,這時候如果要拿外層的類別,需要加上 :: 符號:

class Person
end

module A
  class Person
    def foo
      Person    # 這個會是指 A::Person
      ::Person  # 前面要加 :: 表示要拿最外層的 Person
    end
  end
end

最後,可以用 module 來定義模塊方法:

module MyUtil

  def self.foobar
    puts "foobar"
  end

end

MyUtil.foobar
# 輸出 foobar

Ruby 的 Math API 就是長這種形式。

19. 特性三: 多型(polymorphism)

多型的意思是可以把很多不一樣的東西,當作同一種東西來處理。例如箱子有很多種,打開的實作方式各有不同(有的有鎖、有的沒鎖),但是這些箱子都有提供「打開」這個接口可以操作。下命令的人只需要知道呼叫這個指令即可。

先來示範一個不是物件導向的設計:

box1 = { :name => "Box1", :type => "locked" }
box2 = { :name => "Box2", :type => "unlocked"}
box3 = { :name => "Box3", :type => "seal" }

def open_box(box)
  if box[:type] == "locked"
    puts "Open locked"
  elsif box[:type] == "unlocked"
    puts "Open unlocked"
  elsif box[:type] == "seal"
    puts "Open Seal"
  end
end

arr = [box1, box2, box3]
arr.each do |x|
  open_box(x)
end

上述的代碼中,有一個很厲害的 open_box 方法,在裡面會判斷不同的箱子調用不同的輸出。這樣設計的缺點是不好擴充,維護性低。因為無論是新增不同的箱子,或是修改某一個箱子打開的行為,都得修改同一個方法,複雜度全部集中在 open_box 之中。

讓我們改用物件導向來寫:

class Box
  attr_accessor :name

  def initialize(name)
    @name = name
  end

  def open
    puts "Open default box"
  end
end

class LockedBox < Box
  def open
    puts "Open locked"
  end
end

class UnlockedBox < Box
  def open
    puts "Open Unlocked"
  end
end

class SealBox < Box
  def open
    puts "Open seal"
  end
end

box1 = LockedBox.new("Box1")
box2 = UnlockedBox.new("Box2")
box3 = SealBox.new("Box3")

arr = [box1, box2, box3]
arr.each do |x|
  x.open()
end

代碼看起來好像變多了,但是擴充性和維護性比較好。因為如果要新增不同的箱子,只需要新增類別即可,不需要改到本來的代碼。沒有多型的話,單一函數就會充滿根據數據類型的判斷的 if-else,變得難以擴充。這種多型的特型讓我們不需要擔心確切的數據類型,只要接口一致(都有 open 方法)就可以操作

鴨子型別 Duck Typing

在動態語言中,不同物件只要方法的接口一樣,就可以有多型的特型,這又叫做 Duck Typing:當看到一隻鳥走起來像鴨子、游泳起來像鴨子、叫起來也像鴨子,那麽這只鳥就可以被稱為鴨子。

名詞釋疑:方法的「接口(Interface)」指的是方法的名稱和參數,方法的「實作(Implement)」指的是方法內實際要做的代碼。上述的 LockedBox、UnlockedBox 和 SealBox 都有一樣的 open 接口,但是子類別中定義了不同的實作。

剛剛的 Box 例子是透過繼承來達成多型,因為所有繼承自 Box 的子類別,都一定有 open 方法,自然是多型的。

20. 物件導向設計

到目前為止我們只是基本瞭解定義類別的語法,以及認識了物件導向的三個特性。但是面對一個複雜的軟體好像還是不知道怎麽去設計要有哪些類別?

別擔心,作為應用程式開發的初學者,我們絕大部分需要的類別設計,都由應用程式框架,也就是 Rails 提供了,只要照著框架的規範寫程式即可,一開始不太需要自己設計全新的類別。隨著大型專案的需求,才會逐步需要這方面的知識進行更好的程式架構調整。

這種不依賴框架,自己定義的物件,又叫作 Plain-Old Ruby Object

物件導向設計又是一門學問,有興趣的學員,可以朝以下參考資源搜尋:

  • 物件導向設計實踐指南: Ruby 語言描述 人民郵電
  • SOLID OO 設計原則
  • Design Patterns 設計模式,最有名的即 GoF patterns。設計模式針對了特定的情境,提供設計解法(通常是如何設計你的類別),並且「命名」這些模式讓程序員可以方便溝通和當作命名的元素。另一方面也是提供一種可以臨摹的設計範例。常見的設計模式包括 Factory, Adapter, Composite, Decorator, Iterator, Observer 等等

例如以下是一個 Strategy Pattern 範例:情境是我們想要設計一個通用的數據輸出功能,並且允許我們隨時可以抽換不同的輸出方式,例如 XML 或 JSON。

class AwesomeFormatter

  attr_accessor :data, :formatter

  def initialize(data, formatter)
    self.data = data
    self.formatter = formatter
  end

  def output
    puts self.formatter.output( self.data )
  end

end

class XMLFormatter
  def output(data)
    "<data>#{data}</data>"
  end
end

class JSONFormatter
  def output(data)
    "{ data: #{data} }"
  end
end


formatter = AwesomeFormatter.new( "ihower", XMLFormatter.new )

formatter.output() # 輸出 XML 格式

formatter.formatter = JSONFormatter.new   # 動態更換不同的輸出策略

formatter.output() # 輸出 JSON 格式

在建構 AwesomeFormatter.new 時,第二個參數就是不同的策略,其中 formatter 屬性就代表不同的輸出方式。

透過這個 Strategy Pattern,我們可以隨時抽換不同的輸出方式。

練習作業

請實作一個 class 是汽車,屬性有名稱 name 和型號 modal,都是字串。以及出場年份 year 是日期。三個屬性都有存取方法。

接著實作一個方法回傳車子的年紀、一個方法是輸出格式化的字串是 “出場年份 型號 名稱”。


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