🐕

【構文式を関数で実装するという概念】try式がないなら、自分で関数で実装すればいいじゃない【try-catch式・if式・etc】

に公開

はじめに

プログラミングにおいて、エラーハンドリング(エラー処理)は避けて通れない重要な課題です。
本記事では、近年注目を集めている「try式」について解説し、それが言語仕様として提供されていない場合でも、関数として実装できることを示します。

最近はtry式(try構文のうち、値を返す実装)が増えている

例えばKotlinでは、try-catch(-finally)が式として実装されており、以下のように書くことができます。

val num: Int = try {
  count() // もしかしたら例外を投げるかもしれない関数
} catch (e: ArithmeticException) {
  -1 // エラー時の代替値(returnは不要)
}
println("Result: $num") // count()の結果 or -1

このコードでは、try-catchブロック全体が値を返すため、その結果を直接変数numに代入できます。
count()が成功すればその戻り値が、ArithmeticExceptionが発生すれば-1numに代入されます。

実はHaskellのような歴史ある言語でも、同様の機能が提供されています。※

main :: IO ()
main = do
  result <- possibllyError `catch` (\e ->
    recover e
  )
  print result

Haskellのcatch関数は、エラーが発生した場合に代替の処理を実行し、その結果を値として返します。
possibllyErrorが成功すればその結果が、エラーが発生すればrecover eの結果がresultに束縛されます。

※ これは命令型プログラミングっぽく書いたけど、Haskellはもっとかっこよく書けるんだからねっ!
main :: IO ()
main = do
  result <- possibllyError `catch` recover
  print result
main :: IO ()
main = do
  result <- handle recover possibllyError -- handleはcatch関数の関数が逆のバージョン
  print result
main :: IO ()
main = handle recover possibllyError >>= print

しかしながら、try式は比較的新しい概念として認知され始めたため、メジャーな言語でも実装されていないケースがあります。
例えば、TypeScriptやLuaなどがその例です。

Haskellは昔の言語だけど…

Haskellは遥か昔からtry式に相当する機能を実装しています。
実は、多くのモダンな言語がHaskellの言語仕様やライブラリ実装を参考にして、自身の機能を実装しているのです。

解決策: try式を関数として実装する

try-catch-finally式が言語仕様として提供されていない場合でも、関数として実装することで同等の機能を実現できます。
以下では、Luaを例に具体的な実装方法を示します。

---try-catch-finally式の実装
---@generic T
---@param f fun(): T -- 実行したい処理(エラーを投げる可能性がある)
---@param catch fun(error_message: string): T -- エラーをキャッチして代替値を返す
---@param finally? fun() -- 必ず実行される後処理(省略可能)
function Try(f, catch, finally)
  local result = nil

  -- pcall: Protected Call(保護された呼び出し)
  -- エラーが発生してもプログラムが停止せず、エラー情報を返す
  local ok, err = pcall(f)
  if ok then
    result = ok
  end
  if not ok then
    result = catch(err)
  end

  -- finally処理が指定されていれば実行
  if finally ~= nil then
    finally()
  end
  return result
end

実装の詳細

この関数は、以下の処理フローを実現します。

  1. try部分: pcall(f)で関数fを保護された環境で実行
  2. catch部分: エラーが発生した場合、catch関数でエラーをハンドリング
  3. finally部分: 成功・失敗に関わらず、finally関数を実行(指定されている場合)
  4. 戻り値: tryが成功した場合はその結果を、失敗した場合はcatchの結果を返す

tryが予約語で関数名に使えない言語では、do_try, doTry, try_it, doなど、別の名前を検討すると良いでしょう

使用例

基本的な使い方は以下の通りです。

local num = Try(function()
  return count() -- 実行したい処理
end, function(e)
  return -1 -- エラー時の代替値
end)

count()が成功すればその値が、エラーが発生すれば-1numに代入されます。

前述の実装では、finallyも使用できます。

local num = Try(function()
  return count() -- 実行したい処理
end, function(e)
  return -1 -- エラー時の代替値
end, function()
  print('done') -- 必ず実行される処理
end)

finallyには、リソースの解放やログ出力など、成功・失敗に関わらず必ず実行したい処理を記述します。

前述の実装における注意点: 戻り値がない場合

前述の実装では、戻り値がない場合でも明示的にnilを返す必要があります。

Try(function()
  some_func() -- 戻り値のない関数
  return nil -- 明示的にnilを返す
end, function(e)
  print('error: ' .. tostring(e))
  return nil -- エラー時もnilを返す
end)

これはLuaにvoidという型がなく、Luaでvoidを模倣する場合はnilを使うためです。
具体的には今回の場合、型引数TTryのドキュメントコメント---@generic T)に、voidの代わりにnilを渡し(疑似記法的にはT = nilとも。)、実質的なvoidにしています。

もちろんTintegerを渡し、return nilの代わりにreturn 0などをしても変わりはないですが、戻り値に「整数」という不要な意味合い(情報)が乗ってしまうので、nilを強くおすすめします。

`try-finally`だけ欲しい場合のショートハンド実装

エラーをキャッチせず、finally処理のみを実行したい場合は、以下のようなショートハンド関数を作成できます。

---try()と似ているが、エラーをキャッチせず、finally処理のみを実行する
---@generic T
---@param f fun(): T
---@param finally fun()
function TryFinally(f, finally)
  return Try(f, function(err)
    error(err) -- エラーを再度投げる
  end, finally)
end

使用例:

TryFinally(function()
  foo() -- 実行したい処理
end, function()
  print('done') -- 必ず実行される後処理
end)

この実装では、エラーが発生した場合でもfinallyが実行された後、エラーが再度投げられます。

TypeScriptでの実装例

Luaにはtry構文がないためpcallを使用しましたが、TypeScriptなどtry構文がある言語では、既存のtry構文をラップすることで同様の機能を実現できます。

// 関数名を変更しているのは予約語との衝突を避けるため
// try -> tryIt, catch -> handle, finally -> last
function tryIt<T>(
  f: () => T,
  handle: (e: unknown) => T,
  last?: () => void,
): T {
  try {
    return f() // 実行したい処理
  } catch (e) {
    return handle(e) // エラーハンドリング
  } finally {
    if (last !== undefined) {
      last() // 後処理
    }
  }
}

const num = tryIt(
  () => count(), // 実行したい処理
  (_e) => -1, // エラー時の代替値
)

この実装により、TypeScriptでもtry式と同等の機能を使用できます。

このアプローチにより、エラーハンドリングを関数型プログラミングのスタイルで簡潔に記述できるようになります。

構文式を関数として実装するということ

本記事ではtry式をtry関数として実装しました。
この手法はtryに限らず、例えばif式をif関数として実装することも可能です。

実は「構文を関数で実装する」というアイデアは、特別な名前を持つ概念ではありません。
例えば、遅延評価を実現するために無引数の関数化を行うことは、プログラミングの様々な場面で部分的に使用されています。

if式を関数として実装する例

function ifIt<T>(condition: () => boolean, whenTrue: () => T, whenFalse: () => T): T {
  return condition() ? whenTrue() : whenFalse() // 3つの引数すべてを遅延評価
}

const num = ifIt(() => p(), () => {
  console.log('true') // 補足: if関数なら三項演算子と違って、複数の文を使える
  return count()
}, () => {
  console.log('false')
  return -1
})

この例では、condition, whenTrue, whenFalseの3つすべてを関数でラップすることで遅延評価を実現しています。
条件が真の場合のみwhenTrueが、偽の場合のみwhenFalseが評価されます。

汎用的なアイデア

このように、構文を関数として実装するというアイデアは汎用的であり、様々な場面で応用できます。
言語仕様として提供されていない機能でも、関数として実装することで実現可能なケースが多く存在します。

【完全な余談】遅延評価する必要性とは?

なぜ関数でラップして遅延評価する必要があるのでしょうか?
以下の例で確認してみましょう。

もし関数でラップせず、遅延評価しない実装にすると…

function ifIt<T>(condition: boolean, whenTrue: T, whenFalse: T): T {
  return condition ? whenTrue : whenFalse
}

この実装では正格評価(即座に評価)されるため、以下のように使用した場合:

const num = ifIt(p(), count(), -1)

whenTruewhenFalse両方が評価されてしまいます。

具体例を見てみましょう:

function count(): number {
  console.log('count called')
  return 42
}

const num = ifIt(p(), count(), -count())
// 出力:
// count called
// count called
// ↑ 関数が呼ばれた時点で、両方のconsole.logが即座に実行されている

これでは条件分岐の意味がありません。
条件に応じて一方のみを実行したい場合、関数でラップして遅延評価する必要があるのです。

このため、こういった高階関数では引数を関数でラップする設計が一般的です。

まとめ

本記事では、try式が言語仕様として提供されていない場合でも、関数として実装できることを示しました。

重要なポイント

  1. try式とtry構文の違い: try式は値を返すため、変数への代入や関数の引数として直接使用できます
  2. 関数による実装: 言語仕様として提供されていない機能でも、関数として実装することで同等の機能を実現できます
  3. 遅延評価: 引数を関数でラップすることで、必要な時にのみ評価される遅延評価を実現できます
  4. 汎用的なアイデア: この手法はtryに限らず、ifなど他の構文にも応用できます

実用性

  • Luaの場合: pcallを使用して、try-catch-finally式を関数として実装
  • TypeScriptの場合: 既存のtry構文をラップして、関数として実装
  • どちらの場合も、エラーハンドリングをより簡潔に記述できるようになります

言語仕様として機能が提供されていなくても、関数として実装することで多くの機能を実現できます。
このアイデアは、プログラミングの表現力を高める強力な手法の一つです。

Discussion