軟件構造-Reading 1:靜態檢查

閱讀1:靜態檢查

目標:

  • 學習靜態類型
  • 瞭解好的軟件的三大特性

冰雹序列

   作爲一個運行示例,我們先來了解一下“冰雹序列”,它是這樣定義的:從正整數n開始,如果n是偶數,則下一個數是n/2,否則下一個數是3n+1,直到n等於1。這裏有幾個例子:

2, 1
3, 10, 5, 16, 8, 4, 2, 1
4, 2, 1
2n, 2n-1 , … , 4, 2, 1
5, 16, 8, 4, 2, 1
7, 22, 11, 34, 17, 52, 26, 13, 40, …? (會停止嗎?)

   由於存在3n+1這種變化,所以序列元素的大小可能會忽高忽低——這也是“冰雹序列”名稱的來歷,冰雹在落地前會在雲層中忽上忽下。那麼所有的序列都會最終“落地”變到1嗎?(這個猜想稱爲考拉茲猜想 ,現在還沒有一個好的解決方法。)

計算冰雹序列

下面的代碼用於打印冰雹序列:

// Java
int n = 3;
while (n != 1) {
    System.out.println(n);
    if (n % 2 == 0) {
        n = n / 2;
    } else {
        n = 3 * n + 1;
    }
}
System.out.println(n);
# Python
n = 3
while n != 1:
    print(n)
    if n % 2 == 0:
        n = n / 2
    else:
        n = 3 * n + 1


print(n)

這裏有幾件事值得注意:

  • Java中表達式和語句的基本語義與Python非常相似:例如while,if行爲相同。
  • Java在語句末尾需要分號。額外的標點符號可能會很痛苦,但是它也爲您提供了更多組織代碼的自由——您可以將語句分成多行以提高可讀性。
  • Java在使用 if 和 while的時候,其中的條件判斷需要用括號括起來。
  • Java需要在塊周圍使用花括號,而不是縮進。即使Java不會對多餘的空間給予任何關注,也應始終縮進該塊。編程是一種交流形式,您不僅在與編譯器交流,還與人類交流。人類需要那種縮進。我們稍後會再討論。

類型

   在上面的代碼中,Python和Java最大的不同就是Java需要指定變量n的類型:int
   類型是一些值的集合,以及這些值對應的操作。
   例如下面這5種常用的原始類型 :
int (例如5和-200這樣的整數,但是其範圍有限制,大概在±20億)
long (比int更大範圍的整數)
boolean(對或錯這兩個值)
double (浮點數,其表示的是實數的子集)
char (單個字符例如 ‘A’ 和 ‘$’)
   Java也有對象類型 ,例如:
String 表示一串連續的字符。
BigInteger 表示任意大小的整數。
   從Java的傳統來說,原始類型用小寫字母,對象類型的起始字母用大寫。
   操作符是一些能接受輸入並輸出結果的功能。他們的語法各有區別,Java中常見的有下面這三種:

  • 前綴、中綴、後綴操作符. 例如, a + b 調用這樣一種操作(映射) + : int × int → int( + 是這個操作符的名字, int × int 描述了這兩個輸入, 最後的 int 描述的了輸出)
  • 一個對象的方法. 例如, bigint1.add(bigint2) 調用這樣一種操作(映射) add: BigInteger × BigInteger → BigInteger.
  • 一個函數. 例如: Math.sin(theta) 調用這樣一種操作(映射) sin: double → double. 注意, Math 不是一個對象,它是一個包含sin函數的類。

   有一些操作符可以對不同類型的對象進行操作,這時我們就稱之爲可重載 (overloaded),例如Java中的算術運算符 +, -, *, / 都是可重載的。一些函數也是可重載的。大多數編程語言都有不容程度的重載性。

靜態類型

   Java是一種靜態類型的語言。所有變量的類型在編譯的時候就已經知道了(程序還沒有運行),所以編譯器也可以推測出每一個表達式的類型。例如,如果a和b是int類型的,那麼編譯器就可以知道a+b的結果也是int類型的。事實上,Eclipse在你寫代碼的時候就在做這些檢查,所以你就能夠在編輯的同時發現這些問題。
   在動態類型語言中(例如Python),這種類型檢查是發生在程序運行的時候。
   靜態類型是靜態檢查的一種——檢查發生在編譯的時候。本課程的一個重要目標就是教會你避免bug的產生,靜態檢查就是我們知道的第一種方法。其中靜態類型就阻止了一大部分和類型相關的bug——確切點說,就是將操作符用到了不對應的類型對象上。例如,如果你進行下面這個操作,試圖將兩個字符串進行算術乘法:
   “5” * “6”
   那麼靜態類型檢查就會在你編輯代碼的時候發現這個bug,而不是等到你編譯後運行程序的時候(編譯也不通過)。

靜態檢查、動態檢查、無檢查

編程語言通常能提供以下三種自動檢查的方法:

  • 靜態檢查: bug在程序運行前發現
  • 動態檢查: bug在程序運行中發現
  • 無檢查: 編程語言本身不幫助你發現錯誤,你必須通過特定的條件(例如輸出的結果)檢查代碼的正確性。

   很明顯,靜態檢查好於動態檢查好於不檢查。
   這裏有一些“經驗法則”,告訴你這靜態和動態檢查通常會發現什麼bug:
靜態檢查 :

  • 語法錯誤,例如多餘的標點符號或者錯誤的關鍵詞。即使在動態類型的語言例如Python中也會做這種檢查:如果你有一個多餘的縮進,在運行之前就能發現它。
  • 錯誤的名字,例如Math.sine(2). (應該是 sin.)
  • 參數的個數不對,例如 Math.sin(30, 20).
  • 參數的類型不對 Math.sin(“30”).
  • 錯誤的返回類型 ,例如一個聲明返回int類型函數return “30”;

動態檢查 :

  • 非法的變量值。例如整型變量x、y,表達式x/y 只有在運行後y爲0纔會報錯,否則就是正確的。
  • 無法表示的返回值。例如最後得到的返回值無法用聲明的類型來表示。
  • 越界訪問。例如在一個字符串中使用一個負數索引。
  • 使用一個null對象解引用。(null相當於Python中的None)

   靜態檢查傾向於類型錯誤 ,即與特定的值無關的錯誤。正如上面提到過的,一個類型是一系列值的集合,而靜態類型就是保證變量的值在這個集合中,但是在運行前我們可能不會知道這個值的結果到底是多少。所以如果一個錯誤必須要特定的值來“觸發”(例如除零錯誤和越界訪問),編譯器是不會在編譯的時候報錯的。
   與此相對的,動態類型檢查傾向於特定值纔會觸發的錯誤。

驚喜:原始類型並不是真正的數字!

   在Java和許多其他語言中存在一個“陷阱”——原始數據類型的對象在有些時候並不像真正的數字那樣得到應有的輸出。結果就是本來應該被動態檢查發現的錯誤沒有報錯。例如:

  • 整數的除法:5/2並不會返回一個小數,而是一個去掉小數部分的整數對象,因爲除法操作符對兩個整數對象運算後的結果還是整數,而整數對象是無法表示5/2的精確值的(而我們期望它會是一個動態檢查能發現的錯誤)。
  • 整形溢出: int 和 long類型的值的集合是一個有限集合——它們有最大的值和最小的值,當運算的結果過大或者過小的時候我們就很可能得到一個在合法範圍內的錯誤值。
  • 浮點類型中的特殊值:在浮點類型例如double中有一些不是數的特殊值:NaN ( “Not a Number”), POSITIVE_INFINITY (正無窮), and NEGATIVE_INFINITY (負無窮).當你對浮點數進行運算的時候可能就會得到這些特殊值(例如除零或者對一個負數開更號),如果你拿着這些特殊值繼續做運算,那你可能就會得到一個意想不到結果(譯者注:例如拿NaN和別的數進行比較操作永遠是False) 。

閱讀練習

   讓我們嘗試一些錯誤代碼的示例,看看它們在Java中的行爲。這些錯誤是靜態,動態還是根本不捕獲的?

int n = 5;
if (n) {
  n = n + 1;
}

靜態錯誤

int big = 200000; // 200,000
big = big * big;  // big should be 40 billion now

無報錯,但是得到錯誤的結果

double probability = 1/5;

無報錯,但是得到錯誤的結果

int sum = 0;
int n = 0;
int average = sum/n;

動態錯誤

double sum = 7;
double n = 0;
double average = sum/n;

無報錯,但是得到錯誤的結果

數組和集合

   現在讓我們把“冰雹序列”的結果存儲在數據結構中而不僅僅是輸出。在Java中有兩種常用的線性存儲結構:數組和列表。
   數組是一連串類型相同的元素組成的結構,而且它的長度是固定的(元素個數固定)。例如,我們聲明一個int類型的數組:

int[] a = new int[100];

對於數組,常用的操作符有下:

  • 索引其中的一個元素: a[2]
  • 賦予一個元素特定的值: a[2]=0
  • 求這個數組的長度: a.length (注意和 String.length() 的區別—— a.length 不是一個類內方法調用,你不能在它後面寫上括號和參數)

下面是我們利用數組寫的第一個求“冰雹序列”的代碼,它存在一些bug:

int[] a = new int[100];  // <==== DANGER WILL ROBINSON
int i = 0;
int n = 3;
while (n != 1) {
    a[i] = n;
    i++;  // very common shorthand for i=i+1
    if (n % 2 == 0) {
        n = n / 2;
    } else {
        n = 3 * n + 1;
    }
}
a[i] = n;
i++;

   相信很快你就能發現錯誤:幻數100?(譯者注:幻數是指那些硬編碼的數值)那如果n產生的“冰雹序列”非常長呢?像這樣的bug稱爲越界訪問,在Java中能夠被動態檢查檢測出來,但是在C和C++這樣的語言中則會造成 緩衝區溢出 (能通過編譯),這也是很多漏洞的來源。
   解決方法是使用List類型。列表類型是一個長度可變的序列結構。我們可以這樣聲明列表:

List<Integer> list = new ArrayList<Integer>();

常用的操作符有下:

  • 索引一個元素: list.get(2)
  • 賦予一個元素特定的值: list.set(2, 0)
  • 求列表的長度: list.size()

   這裏要注意List是一個接口,這種類型的對象無法直接用new來構造,必須用能夠實現List要求滿足的操作符的方法來構造。我們會在後來講抽象數據類型的時候具體將價格這個。ArrayList是一個實類型的類(concrete type),它提供了List操作符的具體實現。當然,ArrayList不是唯一的實現方法(還有LinkedList 等),但是是最常用的一個)。你可以在Java API的文檔裏找到很多這方面的信息(Google Java 8 API,這裏的API指“應用程序接口”,它會告訴你Java裏面實現的很多有用的類和方法)。
   另外要注意的是,我們要寫List 而不是 List.因爲List只會處理對象類型而不是原始類型。在Java中,每一個原始類型都有其對應的對象類型(原始類型使用小寫字母名字,例如int,而對象類型的開頭字母大寫,例如Integer)。當我們使用尖括號參量化一個類型時,Java要求我們使用對象類型而非原始類型。在其他的一些情況中,Java會自動在原始類型和對等的對象類型之間相轉換。例如在上面的代碼中我們可以使用Integer i = 0 。

下面是用列表寫的“冰雹序列”的實現:

List<Integer> list = new ArrayList<Integer>();
int n = 3;
while (n != 1) {
    list.add(n);
    if (n % 2 == 0) {
        n = n / 2;
    } else {
        n = 3 * n + 1;
    }
}
list.add(n);

   這樣實現不僅看起來簡潔,更重要的是安全,因爲列表會自動擴充它自己以滿足新添加的元素(當然,直到你的內存不夠用爲止)

迭代

   對於在一個序列結構(例如列表和數組)遍歷元素,Java和Python的寫法差不多:

// find the maximum point of a hailstone sequence stored in list
int max = 0;
for (int x : list) {
    max = Math.max(x, max);
}

   Math.max() 是一個Java API提供的方便的函數。你可以在Google中搜索“java 8 Math”獲得關於Math這個庫的一些詳細信息。

方法

   在Java中,聲明通常必須在一個方法中,而每個方法都要在一個類型中,所以寫“冰雹序列”程序最簡單可以這麼寫:

public class Hailstone {
    /**
     * Compute a hailstone sequence.
     * @param n  Starting number for sequence.  Assumes n > 0.
     * @return hailstone sequence starting with n and ending with 1.
     */
    public static List<Integer> hailstoneSequence(int n) {
        List<Integer> list = new ArrayList<Integer>();
        while (n != 1) {
            list.add(n);
            if (n % 2 == 0) {
                n = n / 2;
            } else {
                n = 3 * n + 1;
            }
        }
        list.add(n);
        return list;
    }
}

   下面介紹一些新的東西。
   public意味着任何在你程序中的代碼都可以訪問這個類或者方法。其他的類型修飾符,例如private ,是用來確保程序的安全性的——它保證了可變類型不會被別處的代碼所修改。我們會在後面的課程中詳細提到。
   static意味這這個方法沒有self這個參數——Java會隱含的實現它,所以你不會看到這個參數。靜態的方法不能通過對象來調用,例如List add() 方法 或者 String length()方法,它們要求先有一個對象。靜態方法的正確調用應該使用類來索引,例如:

Hailstone.hailstoneSequence(83)

   另外,記得在定義的方法前面寫上註釋。這些註釋應該描述了這個方法的功能,輸入輸出/返回,以及注意事項。記住註釋不要寫的囉嗦,而是應該直切要點,簡潔明瞭。例如在上面的代碼中,n是一個整型的變量,這個在聲明的時候int已經體現出來了,就不需要進行註釋。但是如果我們設想的本意是n不能爲負數,而這個編譯器(聲明)是不能檢查和體現出來的,我們就應該註釋出來,方便閱讀理解和修改。
   這些東西我們會在後面的課程中詳細介紹,但是你現在就要開始試着正確使用他們。

變化的值與重新分配變量

   在下一篇閱讀資料中我們會介紹“快照圖”(snapshot diagrams),以此來辨別修改一個變量和修改一個值的區別。當你給一個變量賦值的時候,你實際上是在改變這個變量指向的對象(值也不一樣)。
   而當你對一個可變的值進行賦值操作的時候——例如數組或者列表——你實際上是在改變對象本身的內容。
變化是“邪惡”的,好的程序員會避免可改變的東西,因爲這些改變可能是意料之外的。
   不變性(Immutability)是我們這門課程的一個重要設計原則。不變類型是指那些這種類型的對象一旦創建其內容就不能被更改的類型(至少外部看起來是這樣,我們在後面的的課程中會說一些替代方案)。思考一下在上面的代碼中哪一些類型是可更改類型,哪一些不是?(例如int就是不變的,List就是可變的,給int類型的對象賦值就會讓它指向一個新的對象)
   Java也給我們提供了不變的索引:只要變量被初始化後就不能再次被賦值了——只要在聲明的時候加上final :

final int n = 5;

   如果編譯器發現你的final變量不僅僅是在初始化的時候被“賦值”,那麼它就會報錯。換句話說,final會提供不變索引的靜態檢查。
   正確的使用final是一個好習慣,就好像類型聲明一樣,這不僅會讓編譯器幫助你做靜態檢查,同時別人讀起來也會更順利一些。
   在hailstoneSequence方法中有兩個變量n和list,我們可以將它們聲明爲final嗎?請說明理由。(譯者注:n不行,list可以。因爲我們需要改變n指向的對象,而List對象本身是可以更改的,我們也不需要改變list對應的對象)

public static List<Integer> hailstoneSequence(final int n) { 
    final List<Integer> list = new ArrayList<Integer>();

記錄假設

   在文檔中寫下變量的類型記錄了一個關於它的設想, 例如這個變量總是指向一個整型. 在編譯的時候 Java 就會檢查這個設想, 並且保證在你的代碼中沒有任何一處違背這個設想。
   而使用 final 關鍵字去定義一個變量也是一種記錄設想, 要求這個變量在其被賦值之後就永遠不會再被修改, Java 也會對其進行靜態地檢查。
   不幸的是 Java 並不會自動檢查所有設想,例如:n 必須爲正數。
   爲什麼我們需要寫下我們的設想呢? 因爲編程就是不斷的設想, 如果我們不寫下他們, 就可能會遺忘掉他們, 而且如果以後別人想要閱讀或者修改我們的軟件, 他們就會很難理解代碼, 不得不去猜測(譯者注: 變量的含義/函數的描述/返回值描述等等)

所以在編程的時候我們必須朝着如下兩個目標努力:

  • 與計算機交流. 首先說服編譯器你的程序語法正確並且類型正確, 然後保證邏輯正確, 這樣就可以讓它在運行的時候給我們正確的結果。
  • 與其他人交流. 儘可能使你的程序易於理解, 所以當有人想要在將來某些時候修正它, 改進它或者對其進行適配的時候, 他們可以很方便地實現自己的想法。

黑客派(Hacking)與 工程派(Engineering)

   我們已經在本門課程中編寫了一些黑客風格的代碼, 黑客派的編程風格可以理解爲“放飛自我並且樂觀的”(貶義):

  • 缺點: 在已經編寫大量代碼以後才測試它們
  • 缺點: 將所有的細節都放在腦子裏, 以爲自己可以永遠記住所有的代碼, 而不是將它們編寫在代碼中
  • 缺點: 認爲 BUG 都不存在或者它們都非常容易發現和被修復.

而工程派對應的做法是(褒義):

  • 優點: 一次只寫一點點, 一邊寫一邊測試. 在將來的課程中, 我們將會探討"測試優先編程" (test-first programming)
  • 優點: 記錄代碼的設想、意圖 (document the assumptions that your code depends on)
  • 優點: 靜態代碼檢查將會保護你的代碼不淪爲“愚蠢的代碼”

本課程的目標

本門課程的主要目標爲學習如何生產具有如下屬性的軟件:

  • 遠離bug. 正確性 (現在看起來是正確的), 防禦性 (將來也是正確的)
  • 易讀性. 我們不得不和以後有可能需要理解和修改代碼的程序員進行交流 (修改 BUG 或者添加新的功能), 那個將來的程序員或許會是幾個月或者幾年以後的你, 如果你不進行交流, 那麼到了那個時候, 你將會驚訝於你居然忘記了這麼多, 並且這將會極大地幫助未來的你有一個良好的設計。
  • 可改動性. 軟件總是在更新迭代的, 一些好的設計可以讓這個過程變得非常容易, 但是也有一些設計將會需要讓開發者扔掉或者重構大量的代碼。

   軟件還有其他重要屬性(如性能,可用性,安全性),它們可能會與這三個屬性相抵觸。但是,這些是我們在本次課程中關注的三大巨頭,並且軟件開發人員通常將其放在構建軟件的實踐中。值得考慮的是我們在本課程中學習的每種語言功能,每種編程實踐,每種設計模式,並瞭解它們與三巨頭的關係。

爲什麼使用java?

   安全性是首要原因, Java 有靜態檢查機制 (主要檢查變量類型, 同時也會檢查函數的返回值和函數定義時的返回值類型是否匹配). 我們在這門課中學習軟件開發, 而在軟件開發中一個主要原則就是遠離 BUG, Java 撥號安全性達到了 11 (Java dials safety up to 11), 這讓 Java 成爲一個非常好的用來進行軟件工程實踐的語言. 當然, 在其他的動態語言中也可以寫出安全的代碼, 例如 Python, 但是如果你學習過如何在一個安全的, 具有靜態代碼檢查機制的語言, 你就可以更加容易地理解這一過程。

   普遍性是另一個原因, Java 在科學研究/教育/工業界廣泛被使用. Java 可以在許多平臺運行, 不僅僅是 Windows/Mac/Linux. Java 也可以用來進行 web 開發 (不僅可以在服務端使用, 也可以在客戶端使用), 而且原生安卓系統也是基於 Java 開發. 儘管其他的編程語言也有更加適合用來進行編程教學 (Scheme 和 ML 浮現在腦海中), 但是令人是讓的是這些語言並沒有在現實世界中被廣泛使用. 你的簡歷上的 Java 經驗將會被認爲是一個非常有利的技能. 但是注意請不要理解錯了, 你從本門課程中學到的並不是僅限定於 Java 語言, 這些知識是可以被套用在任何編程語言中的. 本門課程中最重要內容: 安全性, 清晰性, 抽象, 工程化的本能, 這些知識將會讓你遊刃有餘地應對各種編程語言的新潮流。

   任何情況下, 一個好的程序員必須能熟練使用多種編程語言, 編程語言是一種工具, 而你必須使用正確的工具來完成你的工作. 在你完成你在 MIT 的學習生涯之前你肯定會學到其他的編程語言技能 (例如: JavaScript, C/C++, Scheme, Ruby, ML 或者 Haskell) 所以我們正在通過學習第二門語言來入門。

   作爲普遍性的一個結果, Java 有大量的有趣而且有用的庫 (包括 Java 本身自帶的庫, 以及在網絡上的庫), 也有非常多的免費並且完美的工具 (IDE 例如 Eclipse; 編輯器, 編譯器, 測試框架, 性能分析工具, 代碼覆蓋率檢測工具, 代碼風格檢查工具). 即使是 Python , 它的生態系統也沒有 Java 的更加豐富。

後悔使用 Java 的幾個原因:

  • 它很囉嗦, 這個特性使得在黑板上寫出代碼樣例是非常麻煩的.
  • 它很臃腫, 在這些年中已經積累了非常多的不同的功能和特性.
  • 它存在一些內部矛盾, 例如: final 關鍵字在不同的上下文中會有不同的含義, static 關鍵字在 Java 中和靜態代碼檢查並沒有任何關係
  • 它受到C / C ++等老式語言的影響, 原始數據類型和 switch 語句就是很好的例子
  • 它並沒有一個像 Python 一樣的解釋器, 可以允許你在解釋器中編寫一些短小的測試代碼來學習這門語言

   但是總體來說, Java 對現在來說還是一款比較適合用來學習如何編寫安全的, 易於理解的, 對改變友好的代碼的編程語言, 以上就是我們課程的目標;)

摘要

我們今天主要介紹的思想爲靜態代碼檢查, 下面是該思想和我們課程目標的關係:

  • 遠離bug. 靜態代碼檢查可以通過捕捉類型錯誤等其他BUG幫助我們在運行代碼之前就發現它們
  • 易讀性. 它可以幫助我們理解, 因爲所有的類型在代碼中被明確定義 (譯者注: 相比於 Python/PHP 這類動態變量類型的語言)
  • 可改動性. 靜態代碼檢查可以在你在修改你的代碼的時候定位出也需要被修改的地方, 例如: 當你改變一個變量的類型或者名稱的時候, 編譯器立即就會在所有使用到這個變量的地方顯示錯誤, 提示你也需要更新它們。

參考

麻省理工18年春軟件構造課程閱讀01“靜態檢查”
Reading 1: Static Checking

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