假設你有個?string類型的值,而且準備把這個值傳遞給一個參數類型為string的函數。那麼你怎麼把一個類型(?string)轉化為另外一個類型(string)呢?或者假設你有個object類型的值,它可能實作或沒有實作polarizable接口。同時,如果它實作了這個接口,你還希望調用這個object的方法polarize()。那麼類型檢查器如何才能知道polarize()調用是合法的?
在一個良好組織的代碼中,實作一個值是一個類型同時又是另外一個類型的任務情況非常常見。這些看起來非常瑣碎的事情是你必須拿來安撫類型檢查器的關鍵所在。這是hack能夠在開發前期就捕獲問題的關鍵。這也是hack能夠避免像調用一個不存在的方法、在不恰當的地方找到了一個空值,以及其他一些在php代碼庫開發調試中常見的惱人錯誤這些情況的原因。
你有三種類型檢查器使用的方式對這些類型進行提煉轉化,它們是:是否為空檢查、類似is_integer()的内置類型查詢函數,以及instanceof 。當這些語句在流程控制語句(比如循環語句、條件語句)中被使用時,類型推理引擎将會明确知曉:在不同的流程控制路徑下,類型值也不同。
null檢查語句在從空值(nullable)的類型到非空值(non-nullable)類型的轉變中經常用到。下面是個通過了類型檢查器檢查的示例。
function takes_nullable_string(?string $str) {
takes_string($str === null ? "(null)" : $str);
// ...
}<code>`</code>
當然你還可以使用如下的樣式,即把一個分支流直接切斷:
類型檢查器知道,對函數takes_string()的調用僅僅當$info變量不為空時才會被執行。因為如果它為空,if代碼段将會進入,然後函數會直接傳回。(如果return語句改成throw語句,效果是一樣的。)
如下是個更大的例子,展示了一個更加複雜敏感的流程控制:
在return語句這裡,類型檢查器知道$result是個非空的值,是以傳回類型标注就被滿足了。如果if代碼段進入,那麼一個非空的字元串将會配置設定給$result。如果if代碼段沒有進入,那麼$result必須已經是非空的字元串。
最後,hack有個特殊内置的函數叫做invariant(),你可以使用這個函數來向類型檢查器陳述事實。這個函數使用兩個參數,一個是boolean的表達式,另一個是字元串類型,主要用來向讀者描述問題所在 :
在運作環境中,如果invariant()的第一個參數值被證明是false,那麼一個不變異常(invariantexception)将會觸發。類型檢查器知道這并且推斷出:在invariant()函數後面的調用中,$info不會是個空值。否則的話,一個異常早就被抛出了,而代碼根本就不會執行到這個地方。
對每個原始類型來說,這裡有内置的函數來檢測一個變量是否為原始類型(比如is_integer()、is_string()、is_array())。類型檢查器特别識别出這些函數,除了is_object()注7之外。這個知識點将在你檢測mixed類型和泛型時用到。
你使用這些内置的函數來傳遞資訊給類型檢查器的方式,和你使用null檢查的方式非常接近。類型檢查器控制敏感的流程,你可以使用invariant()等。然而,這些内置函數攜帶的資訊比“空或者非空”更複雜。是以這裡我們對它們如何工作做更加細緻的探讨。
首先,類型檢查器不會記住任何負面的資訊,例如“這個值不是個字元串類型”。請看如下示例:
在具體的實踐中,這問題不大。如果我們知道一個值可能是任何類型而不是字元串的話,用途是非常小的。除非在将來能夠對它進一步提煉。
其次,内置的類型查詢功能是唯一的提煉類型到原始類型的辦法。甚至對目前已知類型的值做身份對比也無法實作它:
最終,類型檢查器會明白使用instanceof來檢查某個object是否為給定類或接口的執行個體。就像null檢查和内置的類型查詢一樣,類型檢查器明白條件語句和i
這裡将會有更多的細節,并不像null檢查和内置類型查詢功能,instanceof在處理類型方面能夠以更複雜的方式重疊使用,但是類型檢查器導航它們的能力的确有小的限制。
下面的例子将會展示這個限制,我們有個抽象類型的基類,同時有很多子類。這些子類中,有些實作了内置的接口countable,但有些并沒有實作:
然後我們有個拿baseclass做參數類型的函數,如果傳入的參數是countable的話,我們就調用函數count(),然後調用一個在baseclass裡面聲明的方法。這在面向對象的代碼庫中是非常常見的模式,不僅僅局限于countable接口:
最後一行将會有個類型錯誤,這似乎是完全出乎意料的,是以讓我們來了解一下這個問題的細節所在。
了解這個錯誤的關鍵在于,當類型檢查器看到一個instanceof檢查的時候,它所傳遞的資訊将會是非常嚴格的和類型檢查一緻的資訊,它不會考慮繼承的層次結構、接口,或者任何其他的因素。它甚至有可能不滿足相關的條件(比如baseclass及其繼承類們并沒有實作countable接口),但是類型檢查器不這麼認為。
在函數的開始部分,類型檢查器由于類型标注的緣故,會認為$obj的類型是 baseclass。然而在if代碼段内,類型檢查器會認為$obj是countable類型的,而不是一個實作了countable接口的baseclass執行個體。它會忘記$obj在是countable的同時,還是一個baseclass。
然後我們進入if代碼段後面的部分。這裡,$obj的類型是個包含baseclass或者countable的未決的類型(詳情請見1.6.2節的内容)。然後當類型檢查器看到了代碼 $obj->twist()的時候,它報告了一個錯誤,因為它認為有可能在此調用中$obj的值是非法的,比如它是countable類型而不是baseclass類型。當然你作為讀者,知道這是不可能的。但是類型檢查器不行。
解決這個問題的方法就是對于instanceof檢查使用一個獨立的本地變量。這就可以阻止類型檢查器丢失$obj的類型資訊,這才是這個問題的根源:
在上面描述的所有情形中,在if語句或者invariant()函數調用中,必須是一個單獨的類型查詢。使用或邏輯操作符“||”的聯合多類型查詢是不被類型檢查器支援的。正如下例所示,這将會有個類型錯誤:
一個非常好的解決辦法就是使用接口。建立一個接口然後聲明一個go()方法,讓one和two來實作它,然後在函數f()中對這個接口進行檢查。
目前為止,我們所有示例中的推理都是基于本地變量的。這很容易,因為類型檢查器能夠确信,它能夠看到所有本地變量的讀和寫操作注8。是以它在對本地變量進行類型推理時,能夠做出較強的擔保。
對屬性值的推理是更加困難的。困難的根源在于本地變量在它所在的函數外部無法修改,但是屬性值可以。請分析下面的例子:
這段代碼并不能通過類型檢查器,将會報告如下的錯誤:
錯誤将會指向對函數check_for_valid_characters()的調用。錯誤資訊給出了一個對錯誤的簡單解釋。在null檢查後,類型檢查器知道$this->name并不為空,然而,對函數increment_check_count()的調用執行迫使類型檢查器忘記了$this->name不為空的結論,因為事實可能由于調用的結果而改變。
你作為一名程式員,可能知道$this->name的值不會因為調用increment_check_count()函數的結果而改變,但是類型檢查器自己不能發現這點。正如我們看到的那樣,推理是基于本地函數的。正如錯誤資訊所說的一樣,解決方法就是使用本地變量。複制屬性值到一個本地變量,然後使用這個本地變量進行替換:
你還可以在if代碼段之外寫本地變量的複制語句。然對本地變量進行null檢查。無論如何,都必須讓類型檢查器确認$local_name沒有被修改,然後它就能記住類型非空的推斷了。