如何避免空指標出錯?

banq發表於2018-10-27

一家專門幫助開發人員瞭解生產中發生問題的以色列公司OverOps,對生產過程中出現的最重要的java異常進行了研究。猜猜哪一個處於第一個?空指標異常。並不是因為開發人員忘記新增空值檢查,而是因為開發人員過多使用空值。

所以這些NULL來自何處?
在C#和Java中,所有引用型別都可以指向null。我們可以透過以下方式獲得指向null的引用:

  • “未初始化”的引用型別變數 - 使用空值初始化並隨後賦予其實際值的變數。錯誤可能導致它們永遠不會被重新分配。
  • 未初始化的引用型別類成員。
  • 顯式賦值為null或從函式返回null

以下是我注意到函式返回null的一些模式:

1. 錯誤處理
輸入無效時返回null。這是返回錯誤程式碼的類似方法。我認為這是一種舊式的程式設計風格,起源於不存在例外的時候。

2.實體的可選資料
實體的屬性可以是可選的。如果沒有可選屬性的資料,則返回null。

public class Person
{
    public string FirstName { get; set; }
    // can return null
    public string MiddleName { get; set; }
    public string LastName { get; set; }
}


2.分層模型
在分層模型中,我們通常可以上下導航。當我們處於頂端時,我們需要一種方式來表達,通常是返回null。

interface IEmployee
{
    string FullName { get; }
    string IdNumber { get; }
    ...
    // returns null when the IEmployee is the CEO, because CEO don't have a manager
    IEmployee Manager { get; }
    IEnumerable<IEmployee> ManagerOf{ get; }
}
interface ITreeNode<T>
{
    // returns null when the node is the root
    ITreeNode<T> Parent { get; }
    IEnumerable<ITreeNode<T>> Children{ get; }
    T Data { get; }    
}


3.查詢功能
當我們想要透過集合中的某些條件查詢實體時,我們返回null作為表示未找到實體的方式。
當我們想要根據特定條件在集合中找到一個實體時,我們返回null作為一種說法找不到該實體的方式

class Employe 
{
  string Id { get; set;}
  string Name {get; set;}
  ...
}

class Company
{     
  // returns null if there is no employe with that id
  public Employe FindEmployeById(string id)
  {
    ...
  }    
}


使用空值有什麼問題?
引發NullPointerException的程式碼可能離fix bug很遠。它使得追蹤真正的問題更加困難。特別是如果程式碼是分支的。
在下面的程式碼示例中,有一個錯誤,在A類的某個地方,導致實體為null。但是NullPointerException是在類B的函式內引發的。可以想象現實程式碼可能要複雜得多。

class A
{
  void DoSomething1()
  {
      Entity1 entity = null;
      if (...)
      {
        if (...)
        {
          entity = CreateEntity(1);
        }
      }
      else
      {
        if (...)
        {
          entity = CreateEntity(2);
        }
      }
      
      DoSomething2(entity);
  }
  void DoSomething2(Entity1 entity)
  {
    if(...)
    {
      new B().DoSomething(entity);
    }
  }
}

class B
{
  void DoSomething(Entity1 entity)
  {
    if (...)
    {
      var x = entity.Prop1;
    }
  }
}


第二:會隱藏了錯誤:我遇到了空檢查,看起來開發人員正在思考“我知道我應該檢查null但我不知道當函式返回null時它意味著什麼,我不知道該怎麼做”或“我認為這不能為空,但只是為了確保,我不希望它破壞生產“。它通常看起來像這樣:

 public void registerItem(Item item)
 {
     if (item != null) {
         ItemRegistry registry = peristentStore.getItemRegistry();
         if (registry != null) {
             Item existing = registry.getItem(item.getID());
             if (existing.getBillingPeriod().hasRetailOwner())
             {
                 existing.register(item);
             }
         }
     }
 }

這種型別的空檢查會導致某些邏輯在沒有了解它的情況下不會發生。編寫這種程式碼意味著流的某些邏輯失敗但整個流程成功。它還可能導致某些其他功能中的錯誤,這些功能假定其他功能完成了它的工作。
想象一下,你在網上購買一張演出門票。你有成功的訊息!節目的最後一天到了,你早點下班,安排一個保姆,然後去看節目。當你到達時發現你沒有門票!而且沒有空座位。你回家困難和困惑。你能看到這種空值檢查會如何導致這種情況嗎?

缺少C#和Java中的非Nullable引用型別
在C#和Java中,引用型別始終指向null。如果null是它的有效輸入或輸出,透過檢視函式方法簽名我們是無法瞭解情況的,我相信大多數函式不會返回null或接受null作為入參。
因為很難知道函式是否返回null(除非有記錄),開發人員要麼在不需要時進行空檢查,要麼在需要時不檢查空值(是的,有時在需要時進行空檢查。
這種糟糕的設計選擇導致我之前在“隱藏錯誤”中描述的問題和當然很多NullPointerException。失敗的情況。
Kotlin這樣的語言旨在透過區分可空引用和不可空引用來消除NullPointerException異常。這允許捕獲分配給非空引用的null,並確保開發人員在解除引用可引用引用之前檢查null,所有這些都在編譯時就會實現。
Microsoft透過在C#8中引入Nullable引用型別採用相同的方法。

那我們該怎麼辦?聽取鮑勃叔叔的意見
眾所周知的“鮑勃叔叔” 羅伯特·C·馬丁寫了一本關於清潔程式碼的最著名的書籍(令人驚訝的是)“清潔程式碼”。在Uncle Bob聲稱的這本書中,我們不應該返回null,也不應該將null傳遞給函式。

消除試驗Null的技術模式
我並不是說這是每個場景的最佳解決方案。

1. 將功能拆分為兩個
返回null的每個函式都將轉換為2個函式。一個具有相同簽名的函式丟擲異常而不是返回null。第二個函式返回一個布林值,表示它是否有效以呼叫第一個函式。我們來看一個例子:

class Employe 
{
  string Id { get; set;}
  string Name {get; set;}
  ...
}

class Company
{ 
  public bool ContainsEmployeById(string id)
  {
    ...
  }
  
//如果沒有該id的僱員,將會引發異常

  public Employe FindEmployeById(string id)
  {
    ...
  }    
}
void PayEmploye(HttpRequest httpRequest,Company company)
{
  string id = GetID(httpRequest)  
  if (company.ContainsEmployeById(id))
  {
    var employe = company.FindEmployeById(id);
    PayEmploye(employe);
  }
  else
  {
    ResponseWithError(String.Format("employe with id {0} don't exist" , id));
  }
}


ContainsEmployeById的邏輯基本上與FindEmployeById相同,但不返回員工。這裡會遇到DB效能問題。讓我們介紹一個類似但不同的模式:返回true時的布林函式也會返回我們搜尋的資料。它看起來像這樣:

class Company
{     
  
//如果沒有該id的僱員,將丟擲異常

  public Employe FindEmployeById(string id)
  {
    ...
  }    
  
  
//如果沒有該id的僱員,則返回false

  public bool TryFindEmployeById(string id , ref Employe employe)
  {
  }
}
void PayEmploye(HttpRequest httpRequest,Company company)
{
  string id = GetID(httpRequest);
  Employe employe;
  if (company.FindEmployeById(id , ref employe))
  {  
    PayEmploye(employe);
  }
  else
  {
    ResponseWithError(String.Format("employe with id {0} don't exist" , id));
  }
}

這種模式的一個常見用途是int.Parseint.TryParse
事實上,我可以將一個函式分成兩個函式,每個函式都有自己的用法,這表明返回null是有違反單一責任原則的程式碼氣味。

拆分介面
我們可以從Liskov原則推匯出的實用指南是類必須實現它實現的介面的所有功能。返回null或丟擲異常是不實現函式的方法。因此,返回null是違反Liskov原則的程式碼氣味。
如果一個類不能實現特定介面的函式,我們可以將該函式移動到另一個介面,每個類只實現它可以的介面。

相關文章