70
45

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

思考停止でasync/awaitを使うのはやめよう

Last updated at Posted at 2024-11-02

経験上、「async/awaitはthenより簡潔」程度の理由でasync/awaitの利用が強制されているリポジトリに遭遇することが少なくないです。

自分は思考停止でasync/awaitを優先利用する風潮にはネガティブで、その理由をいくつか挙げたいと思います。

Promiseの理解を妨げる

async/awaitは、非同期処理の理解をすっ飛ばして実装できてしまいます。

そのため、特に初学者においてasync/awaitの簡単さがある種の逃げ道として使われ続けるようなケースでは、Promiseへの慣れや理解を妨げる可能性があります。

クイズ

例えば以下の実装に対し、fetchData実行直後にconsole.log('取得を開始しました')」という出力も追加してみてください。

const data = await fetchData()
console.log('取得が完了しました')

async/awaitではPromiseオブジェクトの存在を意識せず実装できてしまうため、
async/awaitだけに頼ってきた初学者にとっては対応が難しいかもしれません。

ちなみに正解は以下のようになります。

const promise = fetchData()
console.log('取得を開始しました')
const data = await promise
console.log('取得が完了しました')

非効率な実装を生みやすい

非同期処理は、時間のかかる処理の完了を後回しにして他の処理を進められる素晴らしい仕組みです。

async/awaitは、非同期処理を同期処理のように変えてしまい、その利点を損ないやすいです。

例えば、非同期処理を実行した後に、2つの処理を行いとします。
うち一つは「解決後でなければいけない処理」、もう一つは「解決後でなくても進めてよい処理」です。

awaitを使うと、await行以降の処理は全て「解決後」となってしまうため、
以下のように 「解決後でなくても進めてよい処理」が解決後に実行されるような非効率な実装が生まれやすいです。

await fetch()
// 解決後でなくても進めてよい処理
// 解決後でなければいけない処理

それに対しthenでは、コールバック内外で解決前に実行される行と、解決後に実行される行の両方が生まれますので、
適切な実行タイミングでの実装が自然に行われやすいです。

fetch().then(() => {
  // 解決後でなければいけない処理
})
// 解決後でなくても進めてよい処理

awaitでも、以下のように同様のことが可能ですが、ここで重要なのはできる/できないではなく、
一般的なawaitの使われ方において非効率な実装が生まれやすいということです。

const promise = fetch()
// 解決後でなくても進めてよい処理
await promise
// 解決後でなければいけない処理

await行以降には解決後の処理しか書けなくなってしまう

awaitが使われると、その行より下には解決後の処理しか書けなくなってしまいます。

特にエントリーポイントのような多目的な関数においてawaitが使われているとても邪魔くさいです。

main () {
  const data = await fetchData()
  // 他にも色々な処理を書きたいが、
  // 実行タイミングがfetchDataの解決状況に影響を受けてしまう
}

また、既存の処理にawaitを追加する場合は、以降の行の実行タイミングを変えてしまうという問題があります。

main () {
  // 既存の処理
  // 既存の処理
  // 既存の処理

  // ←例えばここらへんに何も考えずに「const data = await fetchData()」を追加するとヤバい

  // 既存の処理
  // 既存の処理
  // 既存の処理
}

1つの関数内で複数の非同期処理を実行できない

エントリーポイントなどにおいて、以下のように複数の非同期処理を行うようなケースは珍しくありません。

main () {
  fetchData1().then(data1 => {
    console.log('データ1の取得が完了', data1)
  })
  fetchData2().then(data2 => {
    console.log('データ2の取得が完了', data2)
  })
}

async/awaitでは、このような処理を行うことができません。

以下では、fetchData1が完了するまでfetchData2が開始されませんし、

main () {
  const data1 = await fetchData1()
  console.log('データ1の取得が完了', data1)
  const data2 = await fetchData2()
  console.log('データ2の取得が完了', data2)
}

以下では、同時に開始こそするものの、それぞれの後続の処理は両方が完了するまで待機されてしまいます。

main () {
  const [data1, data2] = await Promise.all([
    fetchData1(),
    fetchData2()
  ])
  console.log('データ1の取得が完了', data1)
  console.log('データ2の取得が完了', data2)
}

例外処理が面倒

async/awaitでは、thenを避けたついでにcatchも避けてtry-catchが使われたりします。
(本当に使いたいか…?)

try {
  const data = await fetchData()
  doSomething1(data)
  doSomething2(data)
  doSomething3(data)
} catch () {
  dialog('データの取得に失敗しました')
}

上記の例だと、catchではdoSomething1,2,3の例外も拾ってしまう可能性を考慮しなければならず、エラーハンドリングの際に気を使います。

awaitは本当に読みやすいのか

処理の実行順が、上から下へ順に進むため、確かに文章的と言えるとは思います。

しかし、awaitを含む処理は、あたかも一連した流れで実行されていくように見えますが、
実際はそうではなく、awaitまでを処理した後はどこかの遠くの別の処理が実行され、巡り巡って処理完了後にawait以降の処理が実行されます。

// 解決前
// 解決前
// 解決前
const data = await promise
// 解決後
// 解決後
// 解決後

この大きな処理の分断を、awaitは「await行より上か下か」で区切ります。
一方でthenは、コールバックに渡す関数ブロックによって区切られます。

// 解決前
// 解決前
// 解決前
promise.then(data => {
  // 解決後
  // 解決後
  // 解決後
})

個人的にはむしろ、await演算子がどこで使われているか探すより、
関数で区切られているほうが解決前後の分断を視認しやすいように思います。

thenは本当に読みにくいのか

thenとasync/awaitの可読性の比較においては、大袈裟な例が用いられることが多いように思います。

日常的な使われ方においては、わざわざ比較して良し悪しを決めなければならないほど可読性に差は生まれないと感じます。

まとめ

async/awaitを、単にPromise/thenを便利で簡潔にしたものと認識してそれだけを使うのではなく、
それぞれの特徴を正しく理解し、適切に使い分けることが重要だと思います。

70
45
13

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
70
45

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?