💎

【Ruby 9日目】基本文法 - 例外処理

に公開

はじめに

Rubyの例外処理について、Ruby 3.4の仕様に基づいて詳しく解説します。例外処理は、エラーに対処し、プログラムの堅牢性を高めるための重要な機能です。

この記事では、基本的な概念から実践的な使い方まで、具体的なコード例を交えて説明します。

基本概念

例外処理の主な特徴:

  • 例外(Exception): エラーや異常な状況を表すオブジェクト
  • begin-rescue: 例外を捕捉して処理する
  • raise: 例外を発生させる
  • ensure: 必ず実行される処理

Rubyでは例外処理を適切に使用することで、堅牢で保守性の高いコードを書くことができます。

基本的な使い方

begin-rescue-end

# 基本的な例外処理
begin
  # 例外が発生する可能性のあるコード
  result = 10 / 0
rescue
  # 例外が発生した時の処理
  puts "エラーが発生しました"
end

# 例外オブジェクトを取得
begin
  result = 10 / 0
rescue => e
  puts "エラー: #{e.message}"
  puts "クラス: #{e.class}"
end
# => エラー: divided by 0
# => クラス: ZeroDivisionError

特定の例外を捕捉

# 複数の例外を個別に処理
begin
  file = File.open("存在しないファイル.txt")
  content = file.read
rescue Errno::ENOENT => e
  puts "ファイルが見つかりません: #{e.message}"
rescue IOError => e
  puts "I/Oエラー: #{e.message}"
rescue => e
  puts "その他のエラー: #{e.message}"
end

# 複数の例外を同時に捕捉
begin
  # 処理
rescue Errno::ENOENT, Errno::EACCES => e
  puts "ファイルアクセスエラー: #{e.message}"
end

else節

# 例外が発生しなかった場合の処理
begin
  result = 10 / 2
rescue ZeroDivisionError
  puts "0で割ることはできません"
else
  puts "計算成功: #{result}"  # 例外が発生しなかった場合のみ実行
end

ensure節

# 必ず実行される処理
def read_file(filename)
  file = nil
  begin
    file = File.open(filename)
    content = file.read
    return content
  rescue Errno::ENOENT => e
    puts "ファイルが見つかりません: #{e.message}"
    return nil
  ensure
    # 例外の有無に関わらず必ず実行される
    file&.close
    puts "ファイルをクローズしました"
  end
end

メソッド定義内での省略形

# beginを省略できる
def risky_operation
  result = 10 / 0
rescue ZeroDivisionError
  puts "0で割ることはできません"
  return nil
end

risky_operation  # => 0で割ることはできません

retry(再試行)

def fetch_data_with_retry
  retries = 0
  max_retries = 3

  begin
    puts "データ取得中... (試行 #{retries + 1})"

    # ランダムに失敗をシミュレート
    raise "接続エラー" if rand < 0.7

    puts "データ取得成功!"
    return "データ"
  rescue => e
    retries += 1

    if retries < max_retries
      puts "エラー: #{e.message}. 再試行します..."
      sleep 1
      retry  # beginブロックの先頭に戻る
    else
      puts "最大試行回数に達しました"
      raise  # 例外を再発生
    end
  end
end

# fetch_data_with_retry

raise(例外の発生)

# 例外を発生させる
def validate_age(age)
  raise ArgumentError, "年齢は0以上である必要があります" if age < 0
  raise ArgumentError, "年齢は150以下である必要があります" if age > 150

  puts "年齢: #{age}"
end

begin
  validate_age(-5)
rescue ArgumentError => e
  puts "バリデーションエラー: #{e.message}"
end

# 例外クラスを指定して発生
raise StandardError.new("カスタムエラーメッセージ")

# 省略形
raise "エラーが発生しました"  # RuntimeErrorが発生

よくあるユースケース

ケース1: ファイル操作

class FileProcessor
  def process(filename)
    begin
      File.open(filename, "r") do |file|
        content = file.read
        process_content(content)
      end
    rescue Errno::ENOENT
      puts "エラー: ファイル '#{filename}' が見つかりません"
      false
    rescue Errno::EACCES
      puts "エラー: ファイル '#{filename}' へのアクセスが拒否されました"
      false
    rescue => e
      puts "予期しないエラー: #{e.class} - #{e.message}"
      false
    else
      puts "ファイル処理が正常に完了しました"
      true
    ensure
      puts "処理を終了します"
    end
  end

  private

  def process_content(content)
    # コンテンツ処理
    puts "コンテンツを処理中..."
  end
end

# 使用例
processor = FileProcessor.new
processor.process("data.txt")

ケース2: カスタム例外クラス

# カスタム例外の定義
class ValidationError < StandardError
  attr_reader :field, :value

  def initialize(field, value, message)
    @field = field
    @value = value
    super("#{field}のバリデーションエラー: #{message} (値: #{value})")
  end
end

class NotFoundError < StandardError
end

class User
  attr_accessor :name, :email, :age

  def initialize(name:, email:, age:)
    validate_name(name)
    validate_email(email)
    validate_age(age)

    @name = name
    @email = email
    @age = age
  end

  private

  def validate_name(name)
    if name.nil? || name.empty?
      raise ValidationError.new(:name, name, "名前は必須です")
    end

    if name.length < 2
      raise ValidationError.new(:name, name, "名前は2文字以上である必要があります")
    end
  end

  def validate_email(email)
    unless email.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
      raise ValidationError.new(:email, email, "メールアドレスの形式が正しくありません")
    end
  end

  def validate_age(age)
    unless age.is_a?(Integer) && age >= 0 && age <= 150
      raise ValidationError.new(:age, age, "年齢は0〜150の整数である必要があります")
    end
  end
end

# 使用例
begin
  user = User.new(name: "A", email: "invalid", age: 30)
rescue ValidationError => e
  puts "バリデーションエラーが発生しました:"
  puts "  フィールド: #{e.field}"
  puts "  値: #{e.value}"
  puts "  メッセージ: #{e.message}"
end

ケース3: API呼び出しとエラーハンドリング

class ApiClient
  class ApiError < StandardError; end
  class ConnectionError < ApiError; end
  class AuthenticationError < ApiError; end
  class NotFoundError < ApiError; end
  class ServerError < ApiError; end

  def fetch_user(user_id)
    retries = 0
    max_retries = 3

    begin
      response = make_request("/users/#{user_id}")
      handle_response(response)
    rescue ConnectionError => e
      retries += 1
      if retries < max_retries
        puts "接続エラー。#{retries}秒後に再試行します..."
        sleep retries
        retry
      else
        puts "接続エラー: 最大試行回数に達しました"
        raise
      end
    rescue AuthenticationError => e
      puts "認証エラー: #{e.message}"
      nil
    rescue NotFoundError => e
      puts "ユーザーが見つかりません: ID #{user_id}"
      nil
    rescue ServerError => e
      puts "サーバーエラー: #{e.message}"
      nil
    end
  end

  private

  def make_request(path)
    # 仮想的なHTTPリクエスト
    status_code = [200, 401, 404, 500, 503].sample

    {
      status: status_code,
      body: { id: 1, name: "Alice" }
    }
  end

  def handle_response(response)
    case response[:status]
    when 200
      response[:body]
    when 401
      raise AuthenticationError, "認証に失敗しました"
    when 404
      raise NotFoundError, "リソースが見つかりません"
    when 500..599
      raise ServerError, "サーバーエラーが発生しました (#{response[:status]})"
    else
      raise ApiError, "予期しないステータスコード: #{response[:status]}"
    end
  end
end

# 使用例
client = ApiClient.new
user = client.fetch_user(123)
puts user if user

ケース4: トランザクション処理

class BankAccount
  attr_reader :balance

  def initialize(balance)
    @balance = balance
  end

  def withdraw(amount)
    raise ArgumentError, "金額は正の数である必要があります" if amount <= 0
    raise "残高不足です" if amount > @balance

    @balance -= amount
    amount
  end

  def deposit(amount)
    raise ArgumentError, "金額は正の数である必要があります" if amount <= 0

    @balance += amount
  end
end

class TransferService
  class TransferError < StandardError; end

  def transfer(from_account, to_account, amount)
    # トランザクション開始
    original_from = from_account.balance
    original_to = to_account.balance

    begin
      # 出金
      from_account.withdraw(amount)

      # エラーをシミュレート(ランダム)
      raise TransferError, "送金処理中にエラーが発生しました" if rand < 0.3

      # 入金
      to_account.deposit(amount)

      puts "送金成功: #{amount}円"
      true
    rescue => e
      # ロールバック
      puts "エラーが発生しました。トランザクションをロールバックします"
      puts "エラー内容: #{e.message}"

      # 元に戻す(簡易的な実装)
      from_account.instance_variable_set(:@balance, original_from)
      to_account.instance_variable_set(:@balance, original_to)

      false
    end
  end
end

# 使用例
account_a = BankAccount.new(10000)
account_b = BankAccount.new(5000)

service = TransferService.new

puts "送金前:"
puts "口座A: #{account_a.balance}円"
puts "口座B: #{account_b.balance}円"

service.transfer(account_a, account_b, 3000)

puts "\n送金後:"
puts "口座A: #{account_a.balance}円"
puts "口座B: #{account_b.balance}円"

例外クラスの階層

# Rubyの例外クラス階層(主要なもの)
# Exception
#   ├── StandardError(通常捕捉すべき例外)
#   │   ├── ArgumentError
#   │   ├── IOError
#   │   ├── RuntimeError
#   │   ├── TypeError
#   │   ├── NameError
#   │   │   └── NoMethodError
#   │   └── ZeroDivisionError
#   ├── ScriptError
#   │   ├── SyntaxError
#   │   └── LoadError
#   ├── SignalException
#   │   └── Interrupt
#   └── SystemExit

# StandardErrorを継承する(推奨)
class MyError < StandardError
end

# 使用例:特定の例外のみ捕捉
begin
  raise MyError, "カスタムエラー"
rescue StandardError => e
  puts "StandardError系の例外: #{e.message}"
rescue Exception => e
  # これは通常やるべきではない!
  # SystemExitやInterruptまで捕捉してしまう
  puts "すべての例外: #{e.message}"
end

注意点とベストプラクティス

注意点

  1. 例外を握りつぶさない
# BAD: 例外を無視
begin
  risky_operation
rescue
  # 何もしない(危険!)
end

# GOOD: 少なくともログを出力
begin
  risky_operation
rescue => e
  logger.error("エラーが発生しました: #{e.message}")
  logger.error(e.backtrace.join("\n"))
  raise  # 必要に応じて再発生
end
  1. Exceptionを直接捕捉しない
# BAD: SystemExitやInterruptまで捕捉してしまう
begin
  some_operation
rescue Exception => e
  puts "エラー: #{e.message}"
end

# GOOD: StandardErrorを捕捉
begin
  some_operation
rescue StandardError => e
  puts "エラー: #{e.message}"
end

# または具体的な例外を指定
begin
  some_operation
rescue ArgumentError, TypeError => e
  puts "エラー: #{e.message}"
end
  1. 例外を制御フローに使わない
# BAD: 例外を制御フローに使用
def find_user(id)
  user = users[id]
  raise NotFoundError unless user
  user
end

# GOOD: nilを返す
def find_user(id)
  users[id]
end

# または
def find_user!(id)
  users[id] || raise(NotFoundError, "User not found: #{id}")
end

ベストプラクティス

  1. 適切な例外クラスを使う
# GOOD: 明確な例外クラス
class User
  def update_email(email)
    raise ArgumentError, "メールアドレスが不正です" unless valid_email?(email)
    @email = email
  end
end
  1. 例外メッセージは具体的に
# BAD: 情報不足
raise "エラー"

# GOOD: 具体的なメッセージ
raise ArgumentError, "年齢は0〜150の範囲で指定してください(指定値: #{age})"
  1. ensure節でリソースを解放
# GOOD: リソースの確実な解放
def process_file(filename)
  file = File.open(filename)
  begin
    # 処理
  ensure
    file.close
  end
end

# さらに良い: ブロック付きopenを使う
def process_file(filename)
  File.open(filename) do |file|
    # 処理(自動的にcloseされる)
  end
end

Ruby 3.4での改善点

  • エラーメッセージの改善: より詳細でわかりやすいエラーメッセージ
  • スタックトレースの最適化: デバッグがしやすくなった
  • パフォーマンス向上: 例外処理のオーバーヘッドが削減

デバッグ情報の取得

begin
  raise "エラー発生"
rescue => e
  puts "クラス: #{e.class}"
  puts "メッセージ: #{e.message}"
  puts "バックトレース:"
  puts e.backtrace.first(5)  # 最初の5行

  # 詳細情報
  puts "\n詳細:"
  puts e.full_message
end

まとめ

この記事では、例外処理について以下の内容を学びました:

  • begin-rescue-ensure の基本構文
  • 特定の例外の捕捉と複数例外の処理
  • カスタム例外クラスの作成
  • 実践的なエラーハンドリングパターン
  • 注意点とベストプラクティス

例外処理を適切に使用することで、堅牢で保守性の高いRubyコードを書けるようになります。

参考資料

Discussion