《OnJava8》精讀(三) 封裝、複用與多態

在這裏插入圖片描述

介紹


《On Java 8》是什麼?

它是《Thinking In Java》的作者Bruce Eckel基於Java8寫的新書。裏面包含了對Java深入的理解及思想維度的理念。可以比作Java界的“武學祕籍”。任何Java語言的使用者,甚至是非Java使用者但是對面向對象思想有興趣的程序員都該一讀的經典書籍。目前豆瓣評分9.5,是公認的編程經典。

爲什麼要寫這個系列的精讀博文?

由於書籍讀起來時間久,過程漫長,因此產生了寫本精讀系列的最初想法。除此之外,由於中文版是譯版,讀起來還是有較大的生硬感(這種差異並非譯者的翻譯問題,類似英文無法譯出唐詩的原因),這導致我們理解作者意圖需要一點推敲。再加上原書的內容很長,只第一章就多達一萬多字(不含代碼),讀起來就需要大量時間。

所以,如果現在有一個人能替我們先仔細讀一遍,篩選出其中的精華,讓我們可以在地鐵上或者路上不用花太多時間就可以瞭解這邊經典書籍的思想那就最好不過了。於是這個系列誕生了。

一些建議

推薦讀本書的英文版原著。此外,也可以參考本書的中文譯版。我在寫這個系列的時候,會盡量的保證以“陳述”的方式表達原著的內容,也會寫出自己的部分觀點,但是這種觀點會保持理性並儘量少而精。本系列中對於原著的內容會以引用的方式體現。
最重要的一點,大家可以通過博客平臺的評論功能多加交流,這也是學習的一個重要環節。

第六章 初始化和清理


本章總字數:19000

關鍵詞:

  • 構造器
  • this關鍵詞
  • 垃圾回收機制
  • 構造器初始化
  • 數組初始化

構造器

在本章的開始,作者提到了導致編程“不安全”的兩個主要原因:初始化和清理。C語言中的不少bug就是程序員沒有初始化導致的。在C++里加入了構造器的概念。Java沿用了這種機制。並且新加入了垃圾回收機制。
在Java中,如果一個類有構造器,“那 Java 會在用戶使用對象之前(即對象剛創建完成)自動調用對象的構造器方法,從而保證初始化”。

// housekeeping/SimpleConstructor2.java
// Constructors can have arguments

class Rock2 {
    Rock2(int i) {
        System.out.print("Rock " + i + " ");
    }
}

public class SimpleConstructor2 {
    public static void main(String[] args) {
        for (int i = 0; i < 8; i++) {
            new Rock2(i);
        }
    }
}

結果:

Rock 0 Rock 1 Rock 2 Rock 3 Rock 4 Rock 5 Rock 6 Rock 7

構造器沒有返回值,它是一種特殊的方法。但它和返回類型爲 void 的普通方法不同,普通方法可以返回空值,你還能選擇讓它返回別的類型;而構造器沒有返回值,卻同時也沒有給你選擇的餘地(new 表達式雖然返回了剛創建的對象的引用,但構造器本身卻沒有返回任何值)。如果它有返回值,並且你也可以自己選擇讓它返回什麼,那麼編譯器就還得知道接下來該怎麼處理那個返回值(這個返回值沒有接收者)。

有的情況下,我們創建的類(class)沒有爲其設置構造器。那Java會爲我們自動創建一個無參的構造器。如果你顯示的創建了一個構造器,無論是否有參,編譯器就不再默認生成。

this關鍵詞

this,從字面上就可以理解,代表“(當前)這個”。表示當前的對象。當你在程序中寫下 this時表示對當前對象的引用。
示例:

// housekeeping/Flower.java
// Calling constructors with "this"

public class Flower {
    int petalCount = 0;
    String s = "initial value";

    Flower(int petals) {
        petalCount = petals;
        System.out.println("Constructor w/ int arg only, petalCount = " + petalCount);
    }

    Flower(String ss) {
        System.out.println("Constructor w/ string arg only, s = " + ss);
        s = ss;
    }

    Flower(String s, int petals) {
        this(petals);
        //- this(s); // Can't call two!
        this.s = s; // Another use of "this"
        System.out.println("String & int args");
    }

    Flower() {
        this("hi", 47);
        System.out.println("no-arg constructor");
    }

    void printPetalCount() {
        //- this(11); // Not inside constructor!
        System.out.println("petalCount = " + petalCount + " s = " + s);
    }

    public static void main(String[] args) {
        Flower x = new Flower();
        x.printPetalCount();
    }
}

結果:

Constructor w/ int arg only, petalCount = 47
String & int args
no-arg constructor
petalCount = 47 s = hi

記住了 this 關鍵字的內容,你會對 static 修飾的方法有更加深入的理解:static 方法中不會存在 this。你不能在靜態方法中調用非靜態方法(反之可以)。

垃圾回收機制

對於垃圾回收機制,作者提到了兩概念:

  1. 對象可能不被垃圾回收。
  2. 垃圾回收不等同於析構。

Java中沒有析構的概念,如果想要在垃圾回收執行時清除某些對象,可以使用finalize()。但是需要注意finalize()不是析構,Java裏沒有析構。finalize()只會在垃圾回收執行的時候觸發執行。但是finalize()不保證一定會被執行。

你可能會有疑問,爲什麼不保證執行,那它的意義在哪?

記住,無論是"垃圾回收"還是"finalize",都不保證一定會發生。如果 Java 虛擬機(JVM)並未面臨內存耗盡的情形,它可能不會浪費時間執行垃圾回收以恢復內存。

一般只有在內存將要耗盡的時候纔可能觸發垃圾回收。所以這是之所以“不保證執行”的主要原因。

作者提到了finalize()一種用法:

例如,如果對象代表了一個打開的文件,在對象被垃圾回收之前程序員應該關閉這個文件。只要對象中存在沒有被適當清理的部分,程序就存在很隱晦的 bug。finalize() 可以用來最終發現這個情況,儘管它並不總是被調用。如果某次 finalize() 的動作使得 bug 被發現,那麼就可以據此找出問題所在。

關於垃圾回收機制的原理,作者進行了詳細說明。其主要的原理在於“引用計數”概念。

“每個對象中含有一個引用計數器,每當有引用指向該對象時,引用計數加 1。當引用離開作用域或被置爲 null 時,引用計數減 1。因此,管理引用計數是一個開銷不大但是在程序的整個生命週期頻繁發生的負擔。垃圾回收器會遍歷含有全部對象的列表,當發現某個對象的引用計數爲 0 時,就釋放其佔用的空間。”

這讓我想起了C#與之有相似的GC垃圾回收理念。具體可以看《簡說GC垃圾回收》

構造器初始化

讀過前兩章內容我們就知道,在Java中使用一個沒有賦值的對象時會出錯。爲了便於管理,也爲了更好的邏輯性。可以在構造器中初始化。

示例:

// housekeeping/Counter.java

public class Counter {
    int i;

    Counter() {
        i = 7;
    }
    // ...
}

無論創建多少個對象,靜態數據都只佔用一份存儲區域。static 關鍵字不能應用於局部變量,所以只能作用於屬性(字段、域)。如果一個字段是靜態的基本類型,你沒有初始化它,那麼它就會獲得基本類型的標準初值。如果它是對象引用,那麼它的默認初值就是 null。

數組初始化

數組是一系列相同類型的數據統稱。一般由以下幾種初始化數組的方式:

array = new int[ ]{1,2,3,4,5};
int[ ] array = {1,2,3,4,5};
int[ ] array = new int[10]; // 動態初始化數組

其中,動態初始化時,將會用該類型的默認值填充數組。

第七章 封裝


本章總字數:11000
關鍵詞:

  • 變與不變的區分
  • 包的概念
  • 訪問控制

我們在讀舊代碼的時候經常會有一種感覺——這代碼怎麼寫的這麼爛——一看註釋原來是自己寫的。會有這種感覺是因爲我們的能力在提升,對代碼的理解也在改變。如何在不改變代碼功能的前提下優化以往的代碼?這是程序員需要面對的問題。也是重構的由來。

通常,一些用戶(客戶端程序員)希望你的代碼在某些方面保持不變。所以你想修改代碼,但他們希望代碼保持不變。由此引出了面向對象設計中的一個基本問題:“如何區分變動的事物和不變的事物”。

包的概念

包由一系列類組成。這些類有着相同的命名空間。

例如,標準 Java 發佈中有一個工具庫,它被組織在 java.util 命名空間下。java.util 中含有一個類,叫做 ArrayList。使用 ArrayList 的一種方式是用其全名 java.util.ArrayList。

當然,我們也可以用前文提到的import 關鍵詞:

import java.util.ArrayList;
import java.util.* //導入其中所有的類

包的命名有着自己的一套規律。在之前的章節我們提到過URL反轉命名。現在我們要創建一個屬於自己的“獨一無二”的包,該如何做?

比如說我的域名 MindviewInc.com,將之反轉並全部改爲小寫後就是 com.mindviewinc,這將作爲我創建的類的獨一無二的全局名稱。我決定再創建一個名爲 simple 的類庫,從而細分名稱。

package com.mindviewinc.simple;//注意:package 語句必須是文件的第一行非註釋代碼

有了獨一無二的包名之後我們還有一個問題,類名衝突。假如我們在com.mindviewinc.simple 中創建了一個Vector 類。再作出以下引用:

import com.mindviewinc.simple.*;
import java.util.*;

之後使用Vector 類:

Vector v = new Vector();

這時候就會出現報錯——“因爲 java.util.* 也包含了 Vector 類”。因爲Java沒有別名的概念,所以需要用以下方式區分:

java.util.Vector v = new java.util.Vector();

訪問控制

publicprotectedprivate是Java常用的訪問修飾。由於原著中沒有給出詳細的直觀展示,在此我列出一個表格內容。此處的表格參考了Janeiro的內容。

訪問權限 同類 同包 子類 其他包 備註
public 任何人都能訪問
protect × 繼承的類可以訪問
default × × 包訪問權限,即在整個包內均可被訪問
private × × × 除類內部方法外都不能訪問的元素

訪問控制通常被稱爲隱藏實現(implementation hiding)。將數據和方法包裝進類中並把具體實現隱藏被稱作是封裝(encapsulation)。其結果就是一個同時帶有特徵和行爲的數據類型。如果在一組程序中使用接口,而客戶端程序員只能向 public 接口發送消息的話,那麼就可以自由地修改任何不是 public 的事物(例如包訪問權限,protected,或 private 修飾的事物),卻不會破壞客戶端代碼。

第八章 複用


本章總字數:13000
關鍵詞:

  • 組合與繼承
  • 委託
  • final關鍵字

組合與繼承

在之前的章節已經瞭解了組合與繼承的關係。在前文中,作者曾經推薦使用組合而不是繼承。在本章作者着重解釋兩者的區別。

你僅需要把對象的引用(object references)放置在一個新的類裏,這就使用了組合。

繼承是OOP的一個重要部分。在第三章的時候,我們就已經瞭解到“萬物皆對象”,所有的類都隱式的繼承自同一個根Object 。也就意味着我們所創建的所有類實際上都是繼承類。

面對組合,我們可以認爲是A使用了B。而繼承更多的是,A很像B。

示例:

// reuse/Cartoon.java
// (c)2017 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// Constructor calls during inheritance

class Art {
  Art() {
    System.out.println("Art constructor");
  }
}
class Drawing extends Art {
  Drawing() {
    System.out.println("Drawing constructor");
  }
}
public class Cartoon extends Drawing {
  public Cartoon() {
    System.out.println("Cartoon constructor");
  }
  public static void main(String[] args) {
    Cartoon x = new Cartoon();
  }
}

結果:

Art constructor
Drawing constructor
Cartoon constructor

Java 自動在派生類構造函數中插入對基類構造函數的調用。

class Game {
  Game(int i) {
    System.out.println("Game constructor");
  }
}

class BoardGame extends Game {
  BoardGame(int i) {
    super(i);
    System.out.println("BoardGame constructor");
  }
}

如果沒有無參數的基類構造函數,或者必須調用具有參數的基類構造函數,則必須使用 super 關鍵字和適當的參數列表顯式地編寫對基類構造函數的調用

委託

首先,Java不支持委託,但是我們可以使用委託的思想作出類似的功能。

示例:

public class SpaceShipControls {
  void left(int velocity) {}
  void right(int velocity) {}
}
public class SpaceShipDelegation {
  private String name;
  private SpaceShipControls controls =
    new SpaceShipControls();
  public SpaceShipDelegation(String name) {
    this.name = name;
  }
  // Delegated methods:
  public void left(int velocity) {
    controls.left(velocity);
  }
  public void right(int velocity) {
    controls.right(velocity);
  }
  public static void main(String[] args) {
    SpaceShipDelegation protector =
      new SpaceShipDelegation("NSEA Protector");
    protector.left(100);
  }
}

這種方式,使用了SpaceShipControls 的方法,而又不用繼承自它,更像是將將 leftright 的控制交給了SpaceShipControls

組合與繼承的選擇

作者認爲,大多數時候兩者是搭配使用的。但是也需要了解兩者的不同,在需要選擇的時候知道如何取捨。

組合和繼承都允許在新類中放置子對象(組合是顯式的,而繼承是隱式的)。
當你想在新類中包含一個已有類的功能時,使用組合,而非繼承。

作者在本章再次強調在實際項目中,繼承不是必須的,甚至應該儘量少的使用。儘量使用組合而非繼承去實現功能。只有當一個類需要抽出一個基類時才使用它。

一種判斷使用組合還是繼承的最清晰的方法是問一問自己是否需要把新類向上轉型爲基類。如果必須向上轉型,那麼繼承就是必要的,但如果不需要,則要進一步考慮是否該採用繼承。

final關鍵字

final 有三中使用場景:數據方法

數據中使用 final ,代表該值是不能被改變的。但是當修飾的數據是一個對象時則比較特殊,比如下文的v2,“因爲它是引用,所以只是說明它不能指向一個新的對象”。數組也一樣,因爲數組也是一種引用類型。

    private final int i4 = 111;
    static final int INT_5 =222;
    private final Value v2 = new Value(22);

final 也可以使用在參數修飾中。如下文的 with方法是不可以對參數 g進行 new 操作的。

    void with(final Gizmo g) {
        //-g = new Gizmo(); // 非法 -- g is final
    }

    void without(Gizmo g) {
        g = new Gizmo(); // 合法 -- g is not final
        g.spin();
    }

    //void f(final int i) { i++; } // Can't change
    // You can only read from a final primitive
    int g(final int i) {
        return i + 1;
    }

方法中使用final 是爲了明確禁止覆寫。防止被子類改變方法中的行爲。

類中所有的 private 方法都隱式地指定爲 final。因爲不能訪問 private 方法,所以不能覆寫它。可以給 private 方法添加 final 修飾,但是並不能給方法帶來額外的含義。

中使用final 表示該類不能被繼承。因爲有些時候出於安全或者特殊情況考慮,有些類需要永遠杜絕被繼承影響,這時候就用到了final 。但是作者強調,這種方法是有風險的,因爲你很難判斷什麼情況才真的需要杜絕繼承。

第九章 多態


本章總字數:10900
關鍵詞:

  • 方法綁定
  • 可拓展性
  • 構造器與多態
  • 繼承設計

方法綁定

方法綁定是一個很有趣的名詞,或許在這之前你可能都沒有聽說過。如果提到方法綁定分爲“前期綁定”和“後期綁定”就更是雲裏霧裏。說實話我也是在本章內容裏第一次瞭解到編程語言底層的部分實現方式。

作者提到,在C語言中,方法(C裏面叫函數)是隻有一種綁定方式的——前期綁定。前期綁定的意義是,在程序運行之前編譯器已經綁定了對方法(函數)的引用。

相同的道理,後期綁定就是在程序運行開始後,編譯器對方法的綁定。而Java同時包含這兩種綁定方式。

舉一個例子:

Animal m;
...
m=new Cat();//Cat 是Animal的實現
m.eat();

在程序運行之初,編譯器並不知道m將來可能所調用的方法是具體那一種實現。假設 Animal類的實現有很多種:Cat、Dog、Sheep,每種都有 eat,只有在你初始化之後,編譯器纔會綁定eat方法對應的引用——即Cat的 eat

Java 中除了 static 和 final 方法(private 方法也是隱式的 final)外,其他所有方法都是後期綁定。這意味着通常情況下,我們不需要判斷後期綁定是否會發生——它自動發生。

可拓展性

在一個良好的OOP程序中,很多方法會遵循一個完整的標準模型。

舉個例子:
在這裏插入圖片描述
所有的樂器都實現自基類Instrument。並且重寫屬於自己的演奏方法。只有在保持基類一致的前提下,在演奏時,我們可以不需要關注每一個派生類的實現細節,而只需要調用具體的演奏方法即可。這就是多態所帶來的強拓展性。

        Instrument[] orchestra = {
                new Wind(),
                new Percussion(),
                new Stringed(),
                new Brass(),
                new Woodwind()
        };
        for (Instrument i: orchestra) {
            i.play();
        }

構造器與多態

在普通的方法中,動態綁定的調用是在運行時解析的,因爲對象不知道它屬於方法所在的類還是類的派生類。 如果在構造器中調用了動態綁定方法,就會用到那個方法的重寫定義。然而,調用的結果難以預料因爲被重寫的方法在對象被完全構造出來之前已經被調用,這使得一些 bug 很隱蔽,難以發現。

在本節作者拋出了一個疑問:如果在構造器中調用了正在構造的對象的動態綁定方法,會發生什麼?

我們嘗試一下:

// polymorphism/PolyConstructors.java
// Constructors and polymorphism
// don't produce what you might expect
class Glyph {
    void draw() {
        System.out.println("Glyph.draw()");
    }

    Glyph() {
        System.out.println("Glyph() before draw()");
        draw();
        System.out.println("Glyph() after draw()");
    }
}

class RoundGlyph extends Glyph {
    private int radius = 1;

    RoundGlyph(int r) {
        radius = r;
        System.out.println("RoundGlyph.RoundGlyph(), radius = " + radius);
    }

    @Override
    void draw() {
        System.out.println("RoundGlyph.draw(), radius = " + radius);
    }
}

public class PolyConstructors {
    public static void main(String[] args) {
        new RoundGlyph(5);
    }
}

結果:

Glyph() before draw()
RoundGlyph.draw(), radius = 0
Glyph() after draw()
RoundGlyph.RoundGlyph(), radius = 5

Glyph 的 draw() 被設計爲可重寫,在 RoundGlyph 這個方法被重寫。但是 Glyph 的構造器裏調用了這個方法,結果調用了 RoundGlyph 的 draw() 方法,這看起來正是我們的目的。輸出結果表明,當 Glyph 構造器調用了 draw() 時,radius 的值不是默認初始值 1 而是 0。

事實證明,在構造器中調用動態綁定的方法會出現難易捕捉的bug。這就說明我們在構造器內儘量不要像示例那樣使用類中的方法。“在基類的構造器中能安全調用的只有基類的 final 方法”。

總結

本篇博文是目前涉及內容最多的一篇(原著這幾章一共5萬多字)。從晚上寫到凌晨再到第二天校對。對原著相關內容讀了兩、三遍。六至九章的內容主要以多態和封裝、重載爲主。都是OOP最基礎但又最關鍵的內容。也因此將這四章放在一起寫。對OOP的基礎需要多理解才能融會貫通。建議大家多自己敲一敲明白其中的內容。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章