9. 屬性與方法

在物件導向架構中,屬性用來描述物件的特徵。例如人的膚色,頭髮長度、身高體重、性別年齡…等。屬性由屬性名與屬性值這兩部分組成。透過程式,用變數或常數實作出屬性。屬性可放在結構、類別與列舉型態中。

方法指的是物件的操作方式。物件有各式各樣的操作方式,使用函數來實踐他們,例如要一個人跑步就呼叫run()函數,要他走路就呼叫walk()函數。當函數被放在結構、類別或列舉中時,就稱這個函數為方法。

9.1 屬性

屬性類型分為儲存型(stored)與計算型(computed)兩種。儲存型屬性可用於結構、類別或是列舉型態,但計算型屬性只能用於結構與類別。儲存型屬性單純的儲存數值,計算型屬性裡面放程式碼,屬性值是計算出來的。以下面這個BMI結構為例,屬性height與weight屬於儲存型屬性,而bmi則屬於計算型屬性,值是根據height與weight這兩個屬性計算出來的。

struct BMI {
    var height: Double
    var weight: Double
    var bmi: Double {
        weight / ((height / 100) * (height / 100))
    }
}

計算型屬性提供了setter與getter區段,並由其中的程式碼來決定如何存取屬性。其中setter區段是可選擇的,如果沒有實作,代表這個屬性為唯讀屬性,例如上程式碼的屬性bmi屬於唯讀屬性(範例中的bmi只有getter區段)。

9.2 setter 與 getter

計算型屬性的setter區段用來決定得到的屬性值要怎麼處理,例如調整後交給儲存型屬性儲存。在setter區段中,預設的參數名稱newValue為外面傳進來的值。getter區段用來決定屬性值為何,因此使用return將應該呈現的屬性值返回。以攝氏與華氏溫度來說明如何實作getter與setter區段。若我們定義攝氏溫度c為儲存屬性,華氏溫度f為計算屬性,也就是溫度值最後是儲存在屬性c中,華氏溫度的值是根據攝氏溫度計算出來的。若我們將值填入華氏溫度屬性中,這個值會轉為攝氏溫度並儲存在攝氏溫度屬性c中。程式碼如下:

struct Temperator {
    var c = 0.0
    var f: Double {
        set {
            c = ((newValue - 32) / 1.8).rounded()
        }
        
        get {
            return (c * 1.8 + 32).rounded()
        }
    }
}

var temp = Temperator()
temp.f = 50
print(temp.c)
// Prints "10.0"
temp.c = 30
print(temp.f)
// Prints "86.0"

9.3 Lazy 屬性

在屬性宣告前面加上lazy修飾子,代表這個屬性在第一次使用的時候才初始化,而不是隨著結構或類別實體化時就先初始化。先來看一個沒有使用lazy的例子。若每間辦公室都有印表機,所以在Office中宣告了一個印表機屬性,並且宣告的同時就初始化完成。然後我們實體化Office,這時候會看到印表機先初始化完成,然後辦公室才會初始化完成。我們假設印表機要花比較久的時間才完成初始化,這時候就會影響Office完成初始化的時間,但我們不一定每次使用Office都要使用Printer。

struct Printer {
    init() {
        print("印表機準備好了")
    }
}

struct Office {
    var printer = Printer()
    init() {
        print("辦公室開門了")
    }
}

let r201 = Office()
// 印表機準備好了
// 辦公室開門了

為了解決初始化效率的問題,我們可以將屬性printer的初始化時機往後延,延到要用的時候再初始化就好。這時候只要在屬性printer前加上lazy修飾子,若沒有存取printer這個屬性,Printer就不會產生任何實體。下面這個例子從輸出的字串就可以看出來,當Office開門的時候,印表機毫無反應。

struct Office {
    lazy var printer = Printer()
    init() {
        print("辦公室開門了")
    }
}

let r201 = Office()
// 辦公室開門了

lazy還不只是用在這裡,在Collection型態(Array、Set與Dictionary)的標準函數庫中會發現有lazy這個屬性,這個屬性可以作用在map、filter等幾個函數上。以下面這段程式碼為例,一個有10筆資料的陣列,經由map將陣列中的元素值全部都加1,最後我們想要知道索引值2的元素內容為何。

let array = Array(0..<10)
let mapped = array.map { (e) -> Int in
    print("處理\(e)資料")
    return e + 1
}
print(mapped[2])

// 處理0資料
// 處理1資料
// 處理2資料
// 處理3資料
// 處理4資料
// 處理5資料
// 處理6資料
// 處理7資料
// 處理8資料
// 處理9資料
// mapped[2] == 3

這段程式碼執行後會發現,map裡面的程式碼總共執行了10次。雖然我們目前只要看其中一個元素的內容而已,但陣列有10個元素,map中的程式碼自然會執行10次。當陣列數量大的時候,這樣就顯得很沒有效率了,最好的方式是只計算需要計算的元素就好了,其他元素暫時不用處理。現在我們加上lazy再執行看看。

let array = Array(0..<10)
let mapped = array.lazy.map { (e) -> Int in
    print("處理\(e)資料")
    return e + 1
}
print(mapped[2])
// 處理2資料
// mapped[2] == 3

現在會發現,map中的程式只執行了一次而已,因為我們只要看一筆資料,其他沒用到的就不會執行了。加上lazy後,map(_:)的執行效率大幅提昇,但特別要注意的是,使用lazy後,雖然只會計算需要計算的元素,但計算結果並不會儲存起來,因此要再存取同一個元素時,又會再計算一次。所以到底是需要時再計算比較好還是一開始全部計算但只需算一次比較好,需要根據陣列大小與存取狀況來考量,並不是使用lazy就一定比較好。

再來看lazy屬性使用在filter上的例子。想要知道陣列中第一個大於0的數字是多少的時候,filter函數會將陣列內容全部掃描一次,然後將所有大於0的元素集合起來形成一個新陣列並放到result中。最後我們從result陣列取出的第一個元素就是在原本陣列中第一個大於0的元素。

let array = [-3, -1, 5, 7, 10]
let result = array.filter { (e) -> Bool in
    print("處理\(e)資料")
    return e > 0
}

if !result.isEmpty {
    let index = array.firstIndex(of: result[0])!
    print("array[\(index)] == \(result[0])")
}

// 處理-3資料
// 處理-1資料
// 處理5資料
// 處理7資料
// 處理10資料
// array[2] == 5

由array陣列內容可知,其實不用將整個全部掃描完,只要掃描到數字5就可以停止了,這時加上lazy再執行一次看看,從印出的訊息可以發現,現在掃描到5就停止了。但要特別注意的是,當使用了lazy之後,result其實指向原本的陣列而不是一個新陣列,如果使用result[0] 得到的結果是-3,也就是array[0]的內容,其實result[1]就是array[1],result[n]就是array[n]。所以如果要得到預期的數字5,必須使用startIndex這種結構型的索引值。startIndex的使用方式可以參考5章字串與字元,這種索引值跟子字串操作時所用的索引值結構與使用方式是一樣的,這裡就不再重複說明。

let array = [-3, -1, 5, 7, 10]
let result = array.lazy.filter { (e) -> Bool in
    print("處理\(e)資料")
    return e > 0
}

let index = result.startIndex
if index < array.count {
    print("array[\(index)] = \(result[index])")
}
// 處理-3資料
// 處理-1資料
// 處理5資料
// array[2] == 5

9.4 觀察屬性變化

當屬性值有所改變時,我們可以透過willSet與didSet來得知屬性值的變化。willSet會在屬性值改變前呼叫,這時候可以使用系統常數newValue取得即將要寫入的值。didSet會在屬性內容變更完成後呼叫,這時候可以使用系統常數oldValue取得舊的值。例如下面的程式碼中的price屬性,加上了willSet與didSet區段後,只要這個屬性值有所變化就會觸發這兩個區段的程式碼。但初始化時不會觸發,所以初始化價格為60的時候不會產生任何訊息。但是當值要改成80時候,會先觸發willSet然後修改完畢會觸發didSet。

struct Product {
    var price: Int {
        willSet {
            print("新的價格為 \(newValue)")
        }
        didSet {
            print("舊的價格為 \(oldValue)")
        }
    }
}

var p = Product(price: 60)
p.price = 80
// 新的價格為 80
// 舊的價格為 60

如果要在willSet中取得現有的值(也就是舊的值),可以透過「self.屬性」也就是self.price取得,同樣地,如果要在didSet中取得新寫入的值,還是透過self.price取得,因為這時新的值已經寫進去了,所以取得的值就是新的值。此外,lazy屬性不可以加上willSet與didSet。

9.5 Property Wrapper

當我們想要對輸入的屬性值做一些限制時,我們可以透過setter做一些前置處理,例如,有的時候學生成績滿分會超過100分,但最後教務處的成績上限就是100分,因此,只要輸入的成績超過100,儲存的成績資料最多就是100。程式可以這樣寫。

struct Student {
    private var _english: Int = 0
    var english: Int {
        set {
            _english = min(100, newValue)
        }
        
        get {
            return _english
        }
    }
}

var s001 = Student()
s001.english = 150
print(s001.english)
// Prints "100"

但是當有很多屬性都要這樣處理時就變的很繁瑣,因為每一項成績都要宣告兩個屬性,一個是儲存型另一個是計算型。現在我們可以將這些額外的程式碼包成一個特殊的struct,稱為property wrapper,然後讓有需求的屬性都透過這個property wrapper來存取內容,這樣我們就只要宣告一個屬性以及維護一份程式碼就可以了。以成績為例的property wrapper設計如下。另外,property wrapper中的屬性初始化一定要放在初始化器裡面,不允許宣告的同時就初始化。

@propertyWrapper
struct Max100 {
    private var _number: Int
    var wrappedValue: Int {
        set {
            _number = min(100, newValue)
            
        }
        get {
            _number
            
        }
    }

    init() {
        _number = 0
    }
}

上面這個結構就是property wrapper,屬性 _number可以任意命名,但是wrappedValue這個屬性名稱固定不可以改。接下來我們就可以在其他結構或類別中的屬性加上被標示成@propertyWrapper的結構了,如下所示。程式執行後可以看到, computer的成績輸入的是105,但最後實際的內容為100。

struct Student {
    @Max100 var computer: Int
    @Max100 var engligh: Int
}

var s002 = Student()
s002.computer = 105
s002.engligh = 90
print(s002.computer)   
// Prints "100"
print(s002.engligh)    
// Prints "90"

結構Max100除了init()函數外,可以再增加其他的init函數,讓這個wrapper結構使用上更具彈性,以下述程式碼為例,我們加上一個可以任意調整最大值的初始化函數。

@propertyWrapper
struct Max100 {
    private var _number: Int
    private var _maxNumber: Int
    var wrappedValue: Int {
        set {
            _number = min(_maxNumber, newValue)
        }
        get {
            _number
        }
    }

    init() {
        self.init(maxNumber: 100)
    }
    
    init(maxNumber: Int) {
        _number = 0
        _maxNumber = maxNumber
    }
}

假設電腦成績最高分是100分,英文成績最高分是120分,這時可以這樣寫:

struct Student {
    @Max100 var computer: Int
    @Max100(maxNumber: 120) var english: Int
}

var s = Student()
s.computer = 105
s.english = 110
print(s.computer)   
// Prints "100"
print(s.english)    
// Prints "110"

Project Value

在上一個單元的Max100結構中,屬性wrappedValue是外界用來存取屬性_number的橋樑,名稱不可以改。另外還有一個屬性projectedValue,名稱也不可以改,這個屬性用來存取一些額外的資訊,例如我們想要用projectedValue來記錄原始分數。所以我們在wrappedValue的setter裡面將尚未修改過的成績存到projectedValue中。

@propertyWrapper
struct Max100 {
    private var _number: Int
    private var _maxNumber: Int
    var projectedValue: Int
    var wrappedValue: Int {
        set {
            projectedValue = newValue
            _number = min(_maxNumber, newValue)
        }
        get {
            _number
        }
    }

    init() {
        self.init(maxNumber: 100)
    }
    
    init(maxNumber: Int) {
        _number = 0
        _maxNumber = maxNumber
        projectedValue = 0
    }
}

存取屬性projectedValue時只要在原本的屬性名稱前加上「$」就可以存取其內容了,如下:

var s001 = Student()
s001.english = 150
print(s001.$english)
// Prints "150"
print(s001.english)
// Prints "120"

在上面的程式碼中,屬性english儲存的是修改後的成績,而$english儲存的是原始成績。

9.6 實體屬性與型態屬性

實體屬性(instance property)表示在存取時必須先取得該結構或是類別的實體,然後才能存取實體屬性,到目前為止,我們看到的屬性都屬於實體屬性。結構或類別的每一個實體的實體屬性都是獨立的,從記憶體配置的角度來看,每一個實體分別在不同的記憶體區塊,因此實體與實體間的屬性在存取時不會互相干擾。另外一種屬性稱為型態屬性(type property)。型態屬性在存取上不可以透過實體,直接使用「型態名稱.屬性」即可,型態屬性只會存在於一個記憶體區塊中,不會每個實體的記憶體區塊中都有型態屬性。型態屬性也相當於全域屬性,只有一份,在任何地方都可以透過型態名稱來存取。

舉個例子。我們在專案中把所有要用到的參數設定都集中放在Config這個結構中,例如字型、布景主題、語系、顏色…等,在許多應用軟體上可以看到這樣的設計,例如Xcode下拉選單中的「Preferences」中的項目。這些設定值只會有一份,所以很適合使用型態屬性來儲存 這些值。

struct Config {
    static let version = "1.0.0"
    static var theme = "dark"
    static var font = "Arial"
    static var history = 10
    static var language = "Tw"
}

型態屬性存取時也不需要先建立實體,並且由於在記憶體中只有一份,所以在程式中的任何位置透過Config存取的屬性都是同一個。

print(Config.font)
// Prints "Arial"
print(Config.theme)
// Prints "dark"
Config.language = "En"

在第二章我們看過的圓周率p,可以由Double.pi或是Float.pi來取得,屬性pi就是Int型態的型態屬性。

9.7 Singleton

上一節我們瞭解型態屬性不用產生實體就可以存取其內容,而且只佔用一份記憶體。但是有的時候我們又需要多個實體,例如有些應用程式允許有兩位以上的使用者使用,而每位使用者都有自己的喜好設定,這時候就應該要產生兩個以上的實體。如果我們希望可以在實體屬性與型態屬性間可取其優點,也就是存取屬性時可以不用建立實體且有全域屬性的方便,但需要時也可以產生多個實體讓不同實體間的屬性是獨立的,這時就需要使用singleton技術,中文翻譯為「單例」。意思是單一一個實體。

由於結構屬於value type,所以每次將結構所在的記憶體區塊要指定給另外一個變數或常數時,這個記憶體區塊就會複製一份後再給新的變數或常數,所以singleton在結構上沒有作用,除非一直使用同一個變數或常數,不然新的變數或常數就相當於又多一個實體。因此,singleton只能使用於類別型態。

Singleton的設計原理很簡單,透過型態屬性初始化一個實體,然後一直用這個屬性就好了,如下:

class Singleton {
    static let share = Singleton()
    var n = 0
}

現在只要透過屬性share取得的實體永遠都是同一個,然後該實體裡面的屬性n自然也是同一個。像下面這段程式碼從s1改掉屬性n的值後,s2取得的實體其實就跟s1一樣是同一個,因此從s2拿到的n,值會是改掉後的20。

var s1 = Singleton.share
s1.n = 20
var s2 = Singleton.share
print(s2.n)
// Prints "20"

如果需要一個新的實體,只要再初始化一次就好,如下:

var s3 = Singleton()

現在s3跟s1或s2就是不同的實體了。如果要設計成絕對不允許產生第二個實體,永遠只能透過share取得那唯一的一個,只要將初始化器改為private等級,就只能透過屬性share取得唯一的實體了。

class Singleton {
    static let share = Singleton()
    var n = 0

    private init() {
        
    }
}

9.8 方法

結構、類別或是列舉型態中的方法,其實就是函數,只不過這個函數現在被包在一個物件中,所以稱為方法(method),意思是這個物件的操作方法。方法跟屬性一樣分成了實體方法(instance method)與型態方法(type method)兩種。實體方法代表必須先產生實體然後透過該實體去呼叫,例如:

struct Car {
    func run() {
        // 車子開動的程式碼寫這
    }
}

let red = Car()
red.run()

上述程式碼中,想要讓車子跑,就必須要先產生車子的實體,例如red,然後才能透過red.run()讓這部紅色車子開始移動。

若方法前面加上static代表這個方法為型態方法,呼叫時不用產生實體,直接用「類別名稱.方法()」即可,例如:

struct Arithmetic {
    static func plus(_ a: Int, _ b: Int) -> Int {
        a + b
    }
}

let result = Arithmetic.plus(5, 3)
// result == 8

如果型態方法是在類別中,static也可以換成class,作用一樣,像下面這段程式碼我們改用class來定義Arithmetic時,型態方法可以使用static或是class。

class Arithmetic {
    static func plus(_ a: Int, _ b: Int) -> Int {
        a + b
    }
    class func minus(_ a: Int, _ b: Int) -> Int {
        a - b
    }
}

self

保留字self代表自己這個實體。通用在屬性名稱與參數名稱一樣時,名稱前加上self代表該名稱為屬性,例如:

class Car {
    var plate: String
    init(plate: String) {
        self.plate = plate
    }
}

let red = Car(plate: "AAA-1111")

mutating

如果結構或是列舉型態想要在方法中修改本身的屬性值,該方法前必須要加上mutating,否則無法在內部修改自身屬性值,類別因為屬於reference type,因此不會有這個問題。

struct Coordinate {
    var x = 0.0, y = 0.0
    mutating func update(x: Double, y: Double) {
        self.x = x
        self.y = y
    }
}

若呼叫的方法屬於mutating方法,則該實體必須用var來宣告,使用let宣告的實體無法呼叫mutating方法。在預設情況下,結構與列舉型態不允許內部修改屬性值,但是加上mutating後就可以在內部修改屬性值。除此之外,還可以用同樣的技術產生一個新的實體,然後在mutating方法結束之前直接替換掉現有的實體,例如可以這樣做。

struct Coordinate {
    var x = 0.0, y = 0.0
    mutating func update(x: Double, y: Double) {
        self = Coordinate(x: x, y: y)
    }
}

發表迴響