[デザイン パターンの美しさ デザインの原則と考え方: 仕様とリファクタリング] 36 | 実戦 2 (前編): プログラムが失敗したときに何を返すべきか? NULL、例外、エラー コード、空のオブジェクト?

関数演算の結果は 2 つのカテゴリに分けることができます。1 つのカテゴリは、通常の状況で関数が出力する期待される結果です。1 つのカテゴリは予期しない結果です。つまり、異常な (またはエラーと呼ばれる) 状態で関数によって出力される結果です。たとえば、前のレッスンのローカル ホスト名を取得する関数は、通常はローカル ホスト名を文字列形式で返しますが、異常時にはローカル ホスト名の取得に失敗すると、関数はUnknownHostException 例外オブジェクト。

通常の状況では、関数によって返されるデータの型は非常に明確ですが、例外的なケースでは、関数によって返されるデータの型は非常に柔軟であり、多くの選択肢があります。前述の UnknownHostException のような例外オブジェクトに加えて、関数は異常な状況でエラー コード、NULL 値、特別な値 (-1 など)、空のオブジェクト (空の文字列、空のコレクションなど) などを返すこともできます。

各例外戻りデータ型には、独自の特性と適用可能なシナリオがあります。しかし、異常な状況下では、関数が返すデータ型を判断するのがそれほど簡単ではない場合があります。たとえば、前回のレッスンで、ホスト名の取得に失敗した場合、ID ジェネレーターの generate() 関数は何を返す必要があるでしょうか? 異常ですか?ヌル文字?またはNULL値?または他の特別な値 (null-15293834874-fd3A9KBn など、null はホスト名が取得されていないことを意味します)?

関数はコードを記述する非常に重要な単位であり、関数の例外処理は、関数を記述するときに常に考慮しなければならないものです。そのため、今日は、異常な状況下で関数の戻りデータ型を設計する方法について説明します。

早速、今日から本格的に勉強を始めましょう!

前のレッスンの ID ジェネレーター コードから始めましょう。

最後の 2 つのレッスンでは、非常に単純な ID ジェネレータ コードを「使いやすい」から「使いやすい」にリファクタリングしました。最終的なコードは完璧に見えますが、もう一度考えてみると、コードのエラー処理の方法にはまだ最適化の余地があり、これについてもう一度議論する価値があります。
便宜上、前のレッスンのコードをここにコピーしました。

public class RandomIdGenerator implements IdGenerator {
  private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);
  @Override
  public String generate() {
    String substrOfHostName = getLastFiledOfHostName();
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }
  private String getLastFiledOfHostName() {
    String substrOfHostName = null;
    try {
      String hostName = InetAddress.getLocalHost().getHostName();
      substrOfHostName = getLastSubstrSplittedByDot(hostName);
    } catch (UnknownHostException e) {
      logger.warn("Failed to get the host name.", e);
    }
    return substrOfHostName;
  }
  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(String hostName) {
    String[] tokens = hostName.split("\\.");
    String substrOfHostName = tokens[tokens.length - 1];
    return substrOfHostName;
  }
  @VisibleForTesting
  protected String generateRandomAlphameric(int length) {
    char[] randomChars = new char[length];
    int count = 0;
    Random random = new Random();
    while (count < length) {
      int maxAscii = 'z';
      int randomAscii = random.nextInt(maxAscii);
      boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
      boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
      boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
      if (isDigit|| isUppercase || isLowercase) {
        randomChars[count] = (char) (randomAscii);
        ++count;
      }
    }
    return new String(randomChars);
  }
}

このコードには 4 つの関数があります。これら 4 つの関数のエラー処理方法について、次の質問をまとめました。

  • generate() 関数の場合、ホスト名の取得に失敗した場合、関数は何を返しますか? そのような戻り値は妥当ですか?
  • getLastFiledOfHostName() 関数の場合、関数内で UnknownHostException を飲み込む必要がありますか (try-catch してログを出力します)? それとも、引き続き例外をスローする必要がありますか? 上向きにスローされる場合、UnknownHostException をそのままスローするか、新しい例外にカプセル化する必要がありますか?
  • getLastSubstrSplittedByDot(String hostName) 関数で、hostName が NULL または空の文字列の場合、この関数は何を返す必要がありますか?
  • generateRandomAlphameric(int length) 関数で、length が 0 未満または 0 に等しい場合、この関数は何を返す必要がありますか?

上記の質問については、考えてみてください。最初は答えません。このレッスンの理論的な内容を学び終えたら、次のレッスンで一緒に分析します。このセクションでは、いくつかの理論的知識に焦点を当てます。

エラーが発生した場合、関数は何を返す必要がありますか?

関数エラーの戻り値の型について、エラーコード、NULL値、空のオブジェクト、例外オブジェクトの4つの状況をまとめました。次に、それらの使用方法と適用可能なシナリオを 1 つずつ見ていきましょう。

1. エラーコードを返す

C言語には例外などの文法的な仕組みがないため、エラーコードを返すのが最も一般的なエラー処理方法です。Java や Python などの比較的新しいプログラミング言語では、ほとんどの場合、例外を使用して関数エラーを処理し、エラー コードはほとんど使用されません。

C言語では、エラーコードを返す方法として、関数の戻り値を直接占有する方法と、関数の正常実行時の戻り値を出力パラメータに入れる方法と、定義して返す方法の2つがあります。関数内のグローバル変数としてのエラー コード エラーが発生すると、関数の呼び出し元は、このグローバル変数を通じてエラー コードを取得します。これら2つの方法について、さらに説明するために例を挙げます。具体的なコードは次のとおりです。

// 错误码的返回方式一:pathname/flags/mode为入参;fd为出参,存储打开的文件句柄。
int open(const char *pathname, int flags, mode_t mode, int* fd) {
  if (/*文件不存在*/) {
    return EEXIST;
  }
  
  if (/*没有访问权限*/) {
    return EACCESS;
  }
  
  if (/*打开文件成功*/) {
    return SUCCESS; // C语言中的宏定义:#define SUCCESS 0
  }
  // ...
}
//使用举例
int fd;
int result = open(“c:\test.txt”, O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO, &fd);
if (result == SUCCESS) {
  // 取出fd使用
} else if (result == EEXIST) {
  //...
} else if (result == EACESS) {
  //...
}
// 错误码的返回方式二:函数返回打开的文件句柄,错误码放到errno中。
int errno; // 线程安全的全局变量
int open(const char *pathname, int flags, mode_t mode){
  if (/*文件不存在*/) {
    errno = EEXIST;
    return -1;
  }
  
  if (/*没有访问权限*/) {
    errno = EACCESS;
    return -1;
  }
  
  // ...
}
// 使用举例
int hFile = open(“c:\test.txt”, O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO);
if (-1 == hFile) {
  printf("Failed to open file, error no: %d.\n", errno);
  if (errno == EEXIST ) {
    // ...        
  } else if(errno == EACCESS) {
    // ...    
  }
  // ...
}

実際、使い慣れたプログラミング言語に文法的な例外メカニズムがある場合は、エラー コードを使用しないようにしてください。エラー コードと比較して、例外には多くの利点があります。たとえば、より多くのエラー情報を伝えることができます (例外にはメッセージ、スタック トレースなどを含めることができます)。例外については、後で詳しく説明します。

2. NULL 値を返す

ほとんどのプログラミング言語では、NULL を使用して「存在しない」というセマンティクスを表します。ただし、インターネット上では、関数が NULL 値を返すことは設計上の問題であると考えて、推奨しない人が多くいます。

  • 関数が NULL 値を返す可能性がある場合、使用時に NULL 値の判断を忘れて、Null Pointer Exception (略して NPE) がスローされる可能性があります。
  • 戻り値が NULL になる可能性のある関数をたくさん定義すると、NULL 値の判定ロジックが大量に記述されてしまい、書くのが面倒になる一方で、通常のビジネス ロジックと結合されます。 、コードに影響します。

例を挙げて説明しましょう。具体的なコードは次のとおりです。

public class UserService {
  private UserRepo userRepo; // 依赖注入
  
  public User getUser(String telephone) {
    // 如果用户不存在,则返回null
    return null;
  }
}
// 使用函数getUser()
User user = userService.getUser("18917718965");
if (user != null) { // 做NULL值判断,否则有可能会报NPE
  String email = user.getEmail();
  if (email != null) { // 做NULL值判断,否则有可能会报NPE
    String escapedEmail = email.replaceAll("@", "#");
  }
}

では、NULL 値の代わりに例外を使用して、検索ユーザーが存在しない場合に関数が UserNotFoundException をスローするようにすることはできますか?

個人的には、get、find、select、search、query などの単語で始まる検索関数の場合、NULL 値を返すことのすべての欠点にもかかわらず、データがないことは異常ではなく、通常の動作だと思います。したがって、例外を返すよりも、セマンティクスがないことを表す NULL 値を返す方が合理的です。

しかし、そうは言っても、いま述べた理由は特に説得力のあるものではありません。検索データが存在しない場合、関数が NULL 値または例外を使用する必要があるかどうかにかかわらず、重要な参照基準は、プロジェクト全体が統一された規則に従っている限り、プロジェクト内の他の同様の検索関数がどのように定義されているかを確認することです。同意です。プロジェクトがゼロから開発され、参照できる統一された規則とコードがない場合は、2 つのいずれかを選択できます。データが存在しない場合に何が返されるかを呼び出し元が明確に知ることができるように、関数が定義されている場所だけを明確にコメントする必要があります。

検索関数の場合、データ オブジェクトを返すだけでなく、文字列内の別の部分文字列を検索するために使用される Java の indexOf() 関数など、添え字の位置を返すものもあります。最初の発生。関数の戻り値の型は基本型 int です。現時点では、存在しない状況を表すために NULL 値を使用することはできません。この状況では、対処する方法が 2 つあります。1 つは NotFoundException を返す方法で、もう 1 つは -1 などの特別な値を返す方法です。ただし、明らかに -1 の方が合理的です。同じ理由で、「見つかりません」が異常な動作ではなく正常であることを意味します。

3.空のオブジェクトを返す

先ほど述べたように、NULL 値を返すことにはさまざまな欠点があります。この問題に対処するための古典的な戦略は、Null オブジェクト デザイン パターンを適用することです。この設計パターンについては、後の章で詳しく説明するので、今は拡張しません。ただし、今日は、比較的単純で特別な 2 つの空のオブジェクト、つまり空の文字列と空のコレクションについて説明します。

関数によって返されるデータが文字列型またはコレクション型の場合、NULL 値を空の文字列または空のコレクションに置き換えて、それが存在しないことを示すことができます。このように、関数を使用する場合、NULL 値を判断する必要はありません。説明のために例を挙げましょう。具体的なコードは次のとおりです。

// 使用空集合替代NULL
public class UserService {
  private UserRepo userRepo; // 依赖注入
  
  public List<User> getUsers(String telephonePrefix) {
   // 没有查找到数据
    return Collectiosn.emptyList();
  }
}
// getUsers使用示例
List<User> users = userService.getUsers("189");
for (User user : users) { //这里不需要做NULL值判断
  // ...
}
// 使用空字符串替代NULL
public String retrieveUppercaseLetters(String text) {
  // 如果text中没有大写字母,返回空字符串,而非NULL值
  return "";
}
// retrieveUppercaseLetters()使用举例
String uppercaseLetters = retrieveUppercaseLetters("wangzheng");
int length = uppercaseLetters.length();// 不需要做NULL值判断 
System.out.println("Contains " + length + " upper case letters.");

4. 例外オブジェクトを投げる

多くの関数エラーの戻りデータ型については前述しましたが、最も一般的に使用される関数エラー処理の方法は、例外をスローすることです。例外は、関数呼び出しスタック情報など、より多くのエラー情報を運ぶことができます。また、例外により、通常のロジックと例外ロジックの処理を分離できるため、コードの可読性が向上します。

プログラミング言語が異なれば、例外の構文もわずかに異なります。C++ およびほとんどの動的言語 (Python、Ruby、JavaScript など) は、ランタイム例外 (Runtime Exception) という 1 つの例外タイプのみを定義します。また、Java と同様に、実行時例外に加えて、コンパイル時例外 (コンパイル例外) という別の例外タイプが定義されています。

For runtime exceptions, when we write code, we don't need to take a Initiative to try-catch. コンパイラがコードをコンパイルするとき、コードがランタイム例外を処理したかどうかはチェックされません。反対に、コンパイル時の例外については、コードを記述するときに、率先して関数定義で try-catch または宣言を行う必要があります。そうしないと、コンパイルでエラーが報告されます。したがって、実行時例外は非チェック例外 (未チェック例外) とも呼ばれ、コンパイル時例外はチェック例外 (チェック例外) とも呼ばれます。

使い慣れたプログラミング言語で例外タイプが 1 つだけ定義されている場合は、比較的簡単に使用できます。使い慣れたプログラミング言語 (Java など) で 2 つの例外タイプが定義されている場合、例外が発生したときにスローする例外タイプを選択する必要があります。それはチェックされた例外ですか、それともチェックされていない例外ですか?

コードのバグ (配列が範囲外など) や回復不能な例外 (データベースの接続障害など) は、キャッチしてもたいしたことはできないため、未チェックの例外を使用する傾向があります。引き出し額が残高よりも多い例外など、回復可能な例外とビジネス例外については、チェック例外を使用して、それらをキャッチして処理する必要があることを呼び出し元に明確に通知することをお勧めします。

例を挙げて説明すると、コードは次のとおりです。Redis のアドレス (パラメーター アドレス) が設定されていない場合は、デフォルト アドレス (ローカル アドレスやデフォルト ポートなど) を直接使用します; Redis のアドレス形式が正しくない場合、プログラムがフェイル ファーストできることを願っています。 、この状況を回復不能な例外として扱い、実行時例外を直接スローして、プログラムを終了します。

// address格式:"192.131.2.33:7896"
public void parseRedisAddress(String address) {
  this.host = RedisConfig.DEFAULT_HOST;
  this.port = RedisConfig.DEFAULT_PORT;
  
  if (StringUtils.isBlank(address)) {
    return;
  }
  String[] ipAndPort = address.split(":");
  if (ipAndPort.length != 2) {
    throw new RuntimeException("...");
  }
  
  this.host = ipAndPort[0];
  // parseInt()解析失败会抛出NumberFormatException运行时异常
  this.port = Integer.parseInt(ipAndPort[1]);
}

実際、Java でサポートされているチェック済み例外は批判されており、すべての異常な状況では未チェック例外を使用する必要があると多くの人が主張しています。この見解を支持する主な理由は 3 つあります。

  • チェック例外は、関数定義で明示的に宣言する必要があります。関数が多くのチェック例外をスローする場合、関数の定義は非常に冗長になり、コードの可読性に影響を与え、使いにくくなります。
  • コンパイラは、チェックされたすべての例外を明示的にキャッチすることを強制し、コードの実装は面倒になります。非チェック例外はその逆で、宣言を定義に表示する必要がなく、例外をキャッチする必要があるかどうかを自由に決定できます。
  • チェックされた例外の使用は、オープンクローズの原則に違反しています。関数にチェック済み例外を追加する場合、この関数が配置されている関数呼び出しチェーン内のその上にあるすべての関数は、呼び出しチェーン内の関数が新しく追加された例外を追加するまで、対応するコード変更を行う必要があります。処分した。非チェック例外を追加しても、呼び出しチェーンのコードを変更する必要はありません。Spring の AOP アスペクトで例外処理を集中化するなど、特定の機能で処理を集中化することを柔軟に選択できます。

ただし、非チェック例外には欠点もあり、その利点は実際には欠点です。先ほどのステートメントから、非チェック例外はより柔軟に使用できることがわかり、それらをどのように処理するかはプログラマーに委ねられています。また、柔軟すぎると制御不能になることも前述しました. チェックされていない例外は、関数定義で明示的に宣言する必要はありません. 関数を使用するときは、どの例外がスローされるかを知るためにコードをチェックする必要があります. 非チェック例外はキャッチして処理する必要がなく、プログラマーはキャッチして処理する必要があるいくつかの例外を見逃す可能性があります。

チェックされた例外とチェックされていない例外のどちらを使用するかについてオンラインで多くの議論がありますが、一方が他方より優れているという強い理由はありません。したがって、チームの開発習慣に従って、同じプロジェクト内で統一された例外処理仕様を策定するだけで済みます。

2 種類の例外について説明しましたが、関数によってスローされた例外を処理する方法について話しましょう。まとめると、一般的に次の3つの処理方法があります。

  • まっすぐ飲み込む。具体的なコード例は次のとおりです。
public void func1() throws Exception1 {
  // ...
}
public void func2() {
  //...
  try {
    func1();
  } catch(Exception1 e) {
    log.warn("...", e); //吐掉:try-catch打印日志
  }
  //...
}
  • そのまま投げ直します。具体的なコード例は次のとおりです。
public void func1() throws Exception1 {
  // ...
}
public void func2() throws Exception1 {//原封不动的re-throw Exception1
  //...
  func1();
  //...
}
  • 新しい例外の再スローにラップされます。具体的なコード例は次のとおりです。
public void func1() throws Exception1 {
  // ...
}
public void func2() throws Exception2 {
  //...
  try {
    func1();
  } catch(Exception1 e) {
   throw new Exception2("...", e); // wrap成新的Exception2然后re-throw
  }
  //...
}

例外をスローする関数に直面した場合、上記のどの処理方法を選択する必要がありますか? 以下の 3 つの参照原則を要約しました。

  • func1() によってスローされた例外を回復でき、func2() の呼び出し元がこの例外を気にしない場合、func1() によってスローされた例外を func2() で完全に飲み込むことができます。
  • func1() によってスローされた例外が理解可能で、 func2() の呼び出し元を気遣うものであり、ビジネス概念に一定の関連性がある場合、 func1 によってスローされた例外を直接再スローすることを選択できます。
  • func1() によってスローされた例外が低レベルすぎて func2() の呼び出し元が理解できず、ビジネス コンセプトが無関係である場合、呼び出し元が理解できる新しい例外に再パッケージ化してから再スローできます。

要するに、投げ続けるかどうかは、上位層のコードがこの例外を気にするかどうかに依存します。気になる場合は捨ててください。そうでない場合は飲み込んでください。新しい例外にラップしてスローする必要があるかどうかは、上位レベルのコードが例外を理解できるかどうか、およびビジネス関連かどうかによって異なります。理解可能でビジネスに関連している場合は、直接スローできます。そうでない場合は、新しい例外にカプセル化されてスローされます。この部分の理論的な知識については、次のレッスンで ID ジェネレーターのコードと組み合わせてさらに説明します。

キーレビュー

では、本日の内容は以上です。マスターする必要がある主要なコンテンツをまとめて一緒に確認しましょう。

関数 error によって返されるデータ型について、エラー コード、NULL 値、空のオブジェクト、および例外オブジェクトの 4 つの状況をまとめました。

1. エラーコードを返す

C 言語には例外などの文法的なメカニズムがなく、エラー コードを返すことが最も一般的に使用されるエラー処理方法です。Java や Python などの比較的新しいプログラミング言語では、ほとんどの場合、例外を使用して関数エラーを処理し、エラー コードはほとんど使用されません。

2. NULL 値を返す

ほとんどのプログラミング言語では、NULL を使用して「存在しない」というセマンティクスを表します。ルックアップ関数の場合、データが存在しないことは異常な状態ではなく、通常の動作であるため、例外を返すよりも、存在しないというセマンティクスを示す NULL 値を返す方が合理的です。

3.空のオブジェクトを返す

NULL 値を返すことにはさまざまな欠点があり、これに対するより古典的な対策として、null オブジェクトの設計パターンを適用する方法があります。関数によって返されるデータが文字列型またはコレクション型の場合、NULL 値を空の文字列または空のコレクションに置き換えて、それが存在しないことを示すことができます。このように、関数を使用する場合、NULL 値を判断する必要はありません。

4. 例外オブジェクトを投げる

多くの関数エラーの戻りデータ型については前述しましたが、関数エラーを処理する最も一般的な方法は、例外をスローすることです。例外には、チェック例外と非チェック例外の 2 種類があります。

チェックされた例外とチェックされていない例外のどちらを使用するかについてオンラインで多くの議論がありますが、一方が他方より優れているという強い理由はありません。したがって、チームの開発習慣に従って、同じプロジェクト内で統一された例外処理仕様を策定するだけで済みます。

関数によってスローされた例外には、直接飲み込む、直接スローする、新しい例外スローにラップするという 3 つの処理方法があります。この部分は、実際の戦闘と組み合わせた次のレッスンでさらに説明するために残します.

クラスディスカッション

今日学んだ理論的な知識と組み合わせて、RandomIdGenerator の記事の冒頭で述べた 4 つの質問に答えてみてください。
メッセージエリアに答えを書き留めて、クラスメートとコミュニケーションを取り、共有してください。何かを得た場合は、この記事を友達と共有してください。

おすすめ

転載: blog.csdn.net/qq_32907491/article/details/130143072