例外的状況にだけ例外を使う 〜 Seleniumでの要素存在判定を題材に

こんにちは。エン・ジャパン株式会社でVPoEをやっています、こざわです。

弊社、エンジニアに限らず、結構いろんな人がPythonスクリプトを書いて、退屈なことをやらせたりしています。いわゆるRPAというやつですね。

ときどき、それが問題を起こすことがあり、そういったスクリプトをエンジニアが読むことがあります。RPAなのでSeleniumを動かしたりしがちなのですが、そんな中にこんな感じのコードがありました。（説明のため、実際のコードから一部変更しています。）

```python
try:
	driver.find_element(By=CLASS_NAME, "some_class").click()
	do_something()
except NoSuchElementException:
	do_something()
```

どうもコードの意図としては、指定したクラス名を持つ要素が存在したらそれをクリックして、`do_something()` 関数を呼び出す。存在しなくても、`do_something()` を呼び出す、ということをしたいようです。

`do_something()` の中から、`NoSuchElementException`が投げられたら、`do_something()` が二回動いちゃうよね？ということで、少なくともこのコードは、こう書くべきなのですが、

```python
try:
	driver.find_element(By=CLASS_NAME, "some_class").click()
except NoSuchElementException:
	pass
do_something()
```

そもそも、例外が投げられるかどうかで要素の存在をチェックしているのが、好ましくないように思います。

「Selenium 要素 存在判定」や「Selenium existence of element」でググると、この`find_element`して、`NoSuchElementException`が投げられるかどうかで見分ける方法が（多くの場合、`find_elements` による例外を使わない方法と一緒に）案内されており、一見、簡潔に見えますから、そちらを使ってしまうのも仕方がないところかなと思いました。ちなみに、例外は、上記コードでは、`click`ではなく、`find_element`を呼んだタイミングで発生します。

- [Pythonでselenium 要素の存在を判定する方法](https://qiita.com/captainUmaru/items/1d9c1c5e37da986404f1)
- [python seleniumでhtml要素の存在チェックを行う](https://mebee.info/2023/01/19/post-90074/)
- [How to check if element exists in Selenium Python?](https://pythonexamples.org/python-selenium-check-if-element-exists/)

なお、`find_elements` による例外処理を使わない方法とは、リストの長さを調べるもので、たとえばこんな感じです。

```python
elements = driver.find_elements(By=CLASS_NAME, "some_class")
if len(elements) > 0:
	elements[0].click()
	
do_something()
```

一旦リストを作るところが、なんとなくコストが大きく、あるいは迂遠なプログラムを書いているように思えてしまうのかも知れません。

でも、長くプログラミングをしていると、「例外で条件判定をしてはいけない」「例外は例外的なときに使うべき」といった考えにどこかで触れるもので、こちらの書き方がよさそうな感覚を持つはずです。手元の本を読み返してみると、「CODE COMPLETE」「Effective Java」といった本にそういった記述がありました。

> **本当に例外的な状況でのみ例外をスローする**
> 例外は、本当に例外的な状況、つまり他のコーディングプラクティスでは対処できない状況のために残しておこう。例外はアサーションと同じような状況で使用される。つまり、まれにしか発生しないイベントではなく、**絶対に** 発生してはならないイベントで使用される。
> 例外は、予想外の状況に対処する強力な手段と、コードの複雑さの増大とのトレードオフを表す。たとえば、あるルーチンを呼び出すためには、呼び出し元のコードはどこでどの例外がスローされるのかを知らなければならない。したがって、例外はカプセル化を弱め、これによりコードの複雑さが増し、「ソフトウェアの鉄則：複雑さへの対応」にマイナスに働く。
> — Steve McConnell「CODE COMPLETE 第二版 上」(p.243)


> **項目69 例外的状況にだけ例外を使う**
> （中略）
> 実際、例外に基づくイデオムは標準のイデオムよりも遅いです。私のマシンでは、100要素の配列に対して、例外に基づくイデオムは標準イデオムよりも2倍遅いです。
> （中略）
> 例外は、**その名が示すとおり、例外的条件に対してのみ使うべきです。通常の制御フローに対しては、使うべきではありません。** 一般的には、よいパフォーマンスを提供することを意図して過度に凝ったコードよりは、容易に認識できる標準のイデオムを使うべきです。たとえパフォーマンス上の利点が現実にあったとしても、確実に改善され続けているプラットフォーム実装により、利点が失われるかも知れません。しかし、過度に凝ったイデオムによる潜在的なバグや保守の面倒な問題は確実に残ります。
> — ジョシュア・ブロック「Effective Java 第3版」(pp.293-295)


わかりやすさ、バグりにくさのために、例外を（そういった用途に）使うなと言うのは共通しています。

さて最後に、Effective Javaの「2倍遅い」と言うのが気になったので、この二つの方法の場合はどれぐらい違うのか、調べてみました。

```python
# benchmark.py
import chromedriver_binary  # noqa: F401
from timeit import timeit

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.common.exceptions import NoSuchElementException


def build_driver() -> webdriver:
    options = Options()
    options.add_argument('--headless')
    return webdriver.Chrome(options=options)


def check_with_exception(driver: webdriver, class_name: str) -> bool:
    try:
        driver.find_element(By.CLASS_NAME, class_name)
        return True
    except NoSuchElementException:
        return False


def check_without_exception(driver: webdriver, class_name: str) -> bool:
    elements = driver.find_elements(By.CLASS_NAME, class_name)
    return len(elements) > 0


with build_driver() as driver:
    driver.get('https://example.com/')

    functions = [
        lambda: check_with_exception(driver, 'dummy'),
        lambda: check_without_exception(driver, 'dummy')
    ]

    [print(timeit(fn, number=1000)) for fn in functions]


```

```
❯ python benchmark.py
2.814318333
2.580281916999999
```

1000回動かして、0.23秒の違いなので、「2倍」に比べたらそれほど気にするところではなさそうですが、確かに例外処理の方が遅いようです。リストを作ったりするところが、差を縮めているのかも知れません。

でも、たとえ例外を使う方がちょっとばかり速かったとしても、わかりやすさ、バグりにくさのために、例外を条件判定に使わない方がよいよ、ということにしておきたいですね。

そもそも、そういう判定がやりやすいAPIであって欲しいなと思いつつ。