Perl 6 程式設計教學:類別和物件

物件導向程式設計 (object-oriented programming) 是目前主流的程式設計模範 (paradigm),大部分主流的程式語言都支援物件導向程式。本文介紹 Perl 6 的物件系統。

十分鐘的物件導向概論

很多語言都直接在語法機制中支援物件導向,然而,每個語言支援的物件導向特性略有不同,像 C++ 的物件系統相當完整,而 Perl 5 在引入 Moose 前的物件系統則相對原始。物件導向在理論上是和語言無關的程式設計模式,但在實務上卻受到語言特性的影響。學習物件導向時,除了學習在某個特定語言下的實作方式外,更應該學習其抽象層次的思維,有時候,暫時放下實作細節,從更高的視角看物件及物件間訊息的流動,對於學習物件導向有相當的幫助。

物件導向是一種將程式碼以更高的層次組織起來的方法。大部分的物件導向以類別 (class) 為基礎,透過類別可產生實際的物件 (object) 或實體 (instance) ,類別和物件就像是餅乾模子和餅乾的關係,透過同一個模子可以產生很多片餅乾。物件擁有屬性 (field) 和方法 (method),屬性是其內在狀態,而方法是其外在行為。透過物件,狀態和方法是連動的,比起傳統的程序式程式設計,更容易組織程式碼。

許多物件導向語言支援封裝 (encapsulation),透過封裝,程式設計者可以決定物件的那些部分要對外公開,那些部分僅由內部使用,封裝不僅限於靜態的資料,決定物件應該對外公開的行為也是封裝。當多個物件間互動時,封裝可使得程式碼容易維護,反之,過度暴露物件的內在屬性和細部行為會使得程式碼相互糾結,難以除錯。

物件間可以透過組合 (composition) 再利用程式碼。物件的屬性不一定要是基本型別,也可以是其他物件。組合是透過有… (has-a) 關係建立物件間的關連。例如,汽車物件有引擎物件,而引擎物件本身又有許多的狀態和行為。繼承 (inheritance) 是另一個再利用程式碼的方式,透過繼承,子類別 (child class) 可以再利用父類別 (parent class) 的狀態和行為。繼承是透過是… (is-a) 關係建立物件間的關連。例如,研究生物件是學生物件的特例。然而,過度濫用繼承,容易使程式碼間高度相依,造成程式難以維護。可參考組合勝過繼承 (composition over inheritance) 這個指導原則來設計自己的專案。

透過多型 (polymorphism) 使用物件,不需要在意物件的實作,只需依照其公開介面使用即可。例如,我們想用汽車物件執行開車這項行為,不論使用 Honda 汽車物件或是 Ford 汽車物件,都可以執行開車這項行為,而不需在意不同汽車物件間的實作差異。多型有許多種形式,如:

  • 特定多態 (ad hoc polymorphism):
    • 函數重載 (functional overloading):同名而不同參數型別的方法 (method)
    • 運算子重載 (operator overloading) : 對不同型別的物件使用相同運算子 (operator)
  • 泛型 (generics):對不同型別使用相同實作
  • 子類型 (Subtyping):不同子類別共享相同的公開介面,不同語言有不同的繼承機制

以物件導向實作程式,需要從宏觀的角度來思考,不僅要設計單一物件的公開行為,還有物件間如何互動,以達到良好且易於維護的程式碼結構。除了閱讀本書或其他程式設計的書籍以學習如何實作物件外,可閱讀關於物件導向分析及設計 (object-oriented analysis and design) 或是設計模式 (design pattern) 的書籍,以增進對物件導向的了解。

建立類別和物件

使用 class 可以建立類別 (class),再由類別建立物件 (object),如下例:

class Point {
    has Numeric $.x;
    has Numeric $.y;
}

my $p = Point.new(x => 3, y => 4);
$p.x == 3 or die "Wrong value";
$p.y == 4 or die "Wrong value";

如果想要設置固定位置參數,則需修改建構子:

class Point {
    has Numeric $.x;
    has Numeric $.y;

    # Overriding the constructor.
    method new ($x, $y) {
        self.bless(x => $x, y => $y);
    }
}

my $p = Point.new(3, 4);
$p.x == 3 or die "Wrong value";
$p.y == 4 or die "Wrong value";

但這種方式比較不符合 Perl 社群的習慣,因為將參數位置寫死,比較不靈活。

如果想要提供預設值,可略為修改一下 Point 類別:

class Point {
    has Numeric $.x is rw;
    has Numeric $.y is rw;
    
    submethod BUILD(:$x, :$y) {
        with $x {
            self.x = $x;
        } else {
            self.x = 0;
        }
        
        with $y {
            self.y = $y;
        } else {
            self.y = 0;
        }
    }
}

my $o = Point.new();
$o.x == 0 or die "Wrong value";
$o.y == 0 or die "Wrong value";

my $p = Point.new(x => 3, y => 4);
$p.x == 3 or die "Wrong value";
$p.y == 4 or die "Wrong value";

在本例中,屬性 (field) xy 直接對外公開,在實務上,這樣的方式比較不好,因為我們無法控管公開屬性。比較好的方法是將屬性私有化,再用公開方法 (public method) 呼叫,見下文。

宣告方法

我們將 Point 類別改寫如下:

class Point {
    has Numeric $!_x;
    has Numeric $!_y;
    
    submethod BUILD(:$x, :$y) {
        self!x($x);
        self!y($y);
    }
    
    method x() {
        $!_x;
    }
    
    method y() {
        $!_y;
    }
    
    # Private setter for $_x.
    method !x($x) {
        $!_x = $x;
    }
    
    # Private setter for $_y
    method !y($y) {
        $!_y = $y;
    }
}

my $p = Point.new(x => 3, y => 4);
$p.x == 3 or die "Wrong value";
$p.y == 4 or die "Wrong value";

Perl 6 的建構子 (constructor) 預設使用 new 方法,我們要修改建構子的話,就要透過 BUILD 方法來間接修改。另外,在本例中,setter 是私有的,而 getter 是公開的,透過這樣的方式,建立 Point 物件後就不能修改其值。

註:setter 指修改屬性的方法,getter 指取得屬性的方法。

如果我們要將 setter 轉為公開,則需改寫如下:

class Point {
    has Numeric $!_x;
    has Numeric $!_y;
    
    submethod BUILD(:$x, :$y) {
        self.x($x);
        self.y($y);
    }
    
    multi method x() {
        $!_x;
    }
    
    multi method y() {
        $!_y;
    }
    
    multi method x($x) {
        $!_x = $x;
    }
    
    multi method y($y) {
        $!_y = $y;
    }
}

my $p = Point.new(x => 0, y => 0);
$p.x == 0 or die "Wrong value";
$p.y == 0 or die "Wrong value";

$p.x(3);
$p.y(4);
$p.x == 3 or die "Wrong value";
$p.y == 4 or die "Wrong value";

由於 getter 和 setter 都使用同樣的方法名稱,要使用 multi 來重載方法。

雖然在我們這個例子中,暫時看不到使用私有屬性搭配公開方法的好處,這樣修改類別後,就可以控管屬性。例如,我們將 setter 設為唯讀,類別使用者就不能修改屬性;或者,我們可以限定屬性 $!_x$!_y 的範圍等。

類別方法

類別方法 (class method) 指的是不和特定物件綁定的方法,透過類別本身來呼叫,而不透過物件。如下例:

class Point {
    has Numeric $!_x;
    has Numeric $!_y;
    
    submethod BUILD(:$x, :$y) {
        self!x($x);
        self!y($y);
    }
    
    method x() {
        $!_x;
    }
    
    method y() {
        $!_y;
    }
    
    method !x($x) {
        $!_x = $x;
    }
    
    method !y($y) {
        $!_y = $y;
    }
    
    # Class method.
    our sub dist($p, $q) {
        sqrt(($p.x - $q.x) ** 2 + ($p.y - $q.y) ** 2);
    }
}

my $p = Point.new(x => 3, y => 4);
$p.x == 3 or die "Wrong value";
$p.y == 4 or die "Wrong value";

my $q = Point.new(x => 0, y => 0);
my $dist = Point::dist($p, $q);
$dist == 5 or die "Wrong value";

類別屬性

類別屬性不屬於物件,而屬於類別本身。

class Point {
    # Class fields
    my Int $c = 0;
    
    # Instance fields
    has Numeric $!_x;
    has Numeric $!_y;
    
    submethod BUILD(:$x, :$y) {
        self!x($x);
        self!y($y);
        
        $c++;
    }
    
    method x() {
        $!_x;
    }
    
    method y() {
        $!_y;
    }
    
    method !x($x) {
        $!_x = $x;
    }
    
    method !y($y) {
        $!_y = $y;
    }
    
    # Class methods.
    our sub count() {
        $c;
    }
}

my $p = Point.new(x => 1, y => 2);
my $q = Point.new(x => 3, y => 4);
my $r = Point.new(x => 5, y => 6);
Point::count() == 3 or die "Wrong count";

註:經筆者實測,Perl 6 沒有解構子,當物件減少時,本程式會產生 bug。

組合

除了使用基本型別外,也可以將物件組合起來,形成一個新的物件。可見下例:

class Point {
    has Numeric $!_x;
    has Numeric $!_y;
    
    submethod BUILD(:$x, :$y) {
        self!x($x);
        self!y($y);
    }
    
    method x() {
        $!_x;
    }
    
    method !x($x) {
        $!_x = $x;
    }
    
    method y() {
        $!_y;
    }
    
    method !y($y) {
        $!_y = $y;
    }
}

class Rectangle {
    has Numeric $!_width;
    has Numeric $!_height;
    has Point $!_point;
    
    submethod BUILD(:$point, :$width, :$height) {
        self!width($width);
        self!height($height);
        self!point($point);
    }
    
    method width {
        $!_width;
    }
    
    method !width($w) {
        if $w <= 0 {
            die "Invalid width.";
        }
        
        $!_width = $w;
    }
    
    method height {
        $!_height;
    }
    
    method !height($h) {
        if $h <= 0 {
            die "Invalid height";
        }
        
        $!_height = $h;
    }
    
    method point {
        $!_point;
    }
    
    method !point($p) {
        $!_point = $p;
    }
    
    method area {
        $!_width * $!_height;
    }
}

my $r = Rectangle.new(
        width => 10,
        height => 5,
        point => Point.new(x => 2, y => 3),
    );

$r.width == 10 or die "Wrong value";
$r.height == 5 or die "Wrong value";
$r.area == 50 or die "Wrong area";

$r.point.x == 2 or die "Wrong value";
$r.point.y == 3 or die "Wrong value";

在這個例子中,我們將 Point 做為 Rectangle 類別的一部分,藉此重覆使用程式碼。

組合是一種相對簡單的程式碼重用的方式,透過組合,可以將類別的權責劃分出來,避免同一個類別有太多的功能,也就是俗稱的上帝物件 (God object)。

comments powered by Disqus