プログラムは分割しようという話

困難は群れで分けあえ - かばんちゃんさん

Python.jp Discordサーバを始めてから、早いものでもう2年ほど経ちました。登録者は2700名ほどで、初心者の質問なんかもちょくちょく流れてきます。

で、結構いろんな質問を見てるんですが、初心者はもちろん、プログラミングを始めてからけっこうたつような人でも、質問の内容がどうも怪しい、という人がいます。コンピュータやプログラミングの知識以前に、なにか問題を解決するときに、解決するための基礎的な手順ができていない感じの人たちです。プログラミングというのはある意味で問題解決の連続ですから、これがうまくできないとなかなか先に進めません。

なかでも、「問題を分割しない人」 というタイプの人が、プログラミングの勉強に苦労している人たちにはかなり多い感じがしています。

独学でプログラミングを覚えると、最初はこういった手法に気が付きにくいかもしれませんから、簡単にやり方を説明してみようと思います。難しく考えるほどのことではありません。ちょっと練習すればすぐできるようになると思います。

「問題を分割しない人」

StackoverFlowなんかを見てても、よくこんな感じの質問が流れてきます。

Pythonを使ったスクレイピングについて教えて下さい。次のようなプログラムで、スクレイピングしたデータを出力しています。

from bs4 import BeautifulSoup
import requests

def print_data(url):
    html = requests.get(url).text
    soup = BeautifulSoup(html)
    number = soup.select(".data")[0].text
    print(number)

出力されるデータに、数字以外の文字が混じっていて困っています。数字だけを出力するにはどうすればいいでしょうか?

質問の内容には別に問題はありません。しかし、冒頭、この質問者さんが「スクレイピングについて教えて下さい」と言っているのが気になります。

このプログラムは、url で指定したWebページから、data というクラスの要素を検索して出力しています。この手順を詳しく書くと、こんな感じです。

  1. WebページからHTMLを取得する
  2. HTMLから、.data というクラスの要素を検索する
  3. 見つかった要素のテキストを出力する

この「スクレイピング」は、3つの処理に分割できるわけです。

さて、この3つの処理をよく見ると、質問者さんの 数字だけを出力するにはどうすればいいでしょうか という質問は、「スクレイピング」とはあんまり関係がないんですね。3.の、「見つかった要素のテキストを出力する」 という部分だけの問題です。であれば、質問の内容はこんなのが適切なんじゃないでしょうか?

次のようなプログラムで、文字列を出力しています。

def print_text(text):
    print(text)

このプログラムを修正して、文字と数字の混じったテキストから、数字だけを選択して出力したいと思っています。どうすればよいでしょうか。

だいぶ質問が短く、わかりやすくなりました。また、大事なのは、自分がわからないポイントが

スクレイピングして数字を出力する方法

という曖昧なものから、

文字列から数字だけを取り出す方法

という、明快でシンプルなものに変わったことです。これなら本などでもやり方を調べやすいですし、わざわざ質問しなくても、Googleで

Python 文字列 数値 取り出す

などと検索すれば、いくらでも回答が出てきます。

注) ただし、検索結果の上位はゴミみたいなサイトが多くて、あんまり参考にはならないようですが…

こんな風に処理を分割し、問題の範囲を小さく限定することで、問題の解決が飛躍的に楽になるのです。

分割統治

こんなに小さい、数行しかないようなプログラムでも、複数の機能の組み合わせからできています。プログラムを書くときには、この「機能」をいい感じに切り分け、独立した部品として構成するように頑張ってみましょう。

処理の分割は、プログラミングでもっとも基礎的かつ重要なテクニックです。プログラマは、毎日毎日こんな分割をしながら生活しています。ソフトウェア工学にはいろいろな開発手法がありますが、突き詰めれば「どうすればよりよく問題を分割できるか」という方法論の追求とも言えるのです。

こういったテクニックのことを、古代ローマ帝国が属国を支配した方法になぞらえて、「分割統治」 と言ったりします。ローマ帝国は、征服した都市が他の都市と連絡することを禁止して、反乱をふせいだそうです。ひどいですね。でも、ローマ帝国はこの方法で1000年近くも栄えていたそうですから、われわれもマネをして1000年の平和を享受したいものです。

練習として、先ほどのプログラムを、もうすこし整理してみましょう。とりあえず、3つの機能をコメントとして書き込んでみます。

def print_data(url):
    # 1. WebページからHTMLを取得する
    html = requests.get(url).text

    # 2. HTMLから、`.data` というクラスの要素を検索する
    soup = BeautifulSoup(html) 
    number = soup.select(".data")[0].text

    # 3. 見つかった要素のテキストを出力する
    print(number)

それぞれの処理を、関数として独立させてみましょう。関数に渡す引数と戻り値はなんなのか、しっかりと考えて分割するのが大事です。

def get_html(url):
    """url で指定したWebページからHTMLを取得する"""
    return requests.get(url).text

def find_data_element(html):
    """html から .dataクラスの要素を検索し、一番目の要素のテキストを返す"""
    soup = BeautifulSoup(html) 
    return soup.select(".data")[0].text

def print_elem_data(text):
    """テキストを出力する"""
    print(text)

def print_data(url):
    # 1. WebサイトからHTMLを取得する
    html = get_html(url)

    # 2. HTMLから、`.data` というクラスの要素を検索する
    number = find_data_element(html)

    # 3. 見つかった要素のテキストを出力する
    print_elem_data(number)

元々のプログラムは十分に小さくて見通しが良いので、実際のプロジェクトではここまで分割しないかもしれません。しかし、こうして整理してみると

  1. プログラム全体の目的と構成がわかりやすい
  2. それぞれの機能の処理がわかりやすい
  3. それぞれの機能の引数と戻り値が明確になって、データの流れがわかりやすい
  4. それぞれの機能をテストしやすい

など、多くのメリットがあります。こういう風に、プログラムの機能を変えずに構造を改善することを、リファクタリングとも言います。

テストする

特に、4. それぞれの機能をテストしやすい は重要です。

このプログラムで、数字だけのデータと、アルファベット混じりのデータでそれぞれ出力を確認してみたいとき、どうやって実行すればいいでしょう?

元々のプログラムだと、全部の処理がひとまとめになってしまっていますね。

def print_data(url):
    html = requests.get(url).text
    soup = BeautifulSoup(html)
    number = soup.select(".data")[0]
    print(number.text)

これだと、本物のWebサーバに数字版のページと、アルファベット版のページをそれぞれ用意して、

print_data("https://example.com/number_only_page.html")
print_data("https://example.com/number_and_alphabet_page.html")

みたいに実行して試すしかありません。これではとてもとても面倒ですし、そんなに簡単にテスト用のWebサイトを用意できるとも限りません。

しかし、リファクタリングしたバージョンなら、print_elem_data() を使って

print_elem_data("1234567")
print_elem_data("1a2b3c4d")

と、簡単に実験できます。こんな風に、機能のテストをしやすい単位を意識すると、プログラムを分割するときの良い目安になります。

テストもできるようになりましたから、実際にprint_elem_data()を修正をしてみましょう。文字列から数字だけを取り出す方法はいろいろありますが、ここではこんな感じにしてみましょう。

import re

def print_elem_data(text):
    """テキストから数字だけを取り出して出力する"""
    digits = re.sub(r"[^0-9]+", "", text)
    return print(digits)

独立した関数になってますから、テストも簡単です。

>>> print_elem_data("123abc456def789")
123456789

分割しよう!

ある程度プログラミングに慣れてきたら、こんな感じに機能の分割を意識してみましょう。

分割したプログラムはわかりやすくなりますし、困ったときやBugが出たとき、問題が細かく分解されているので解決が簡単になります。

分割する方法は、最初のうちはまあなんでもいいです。分割にはいろんな方法や考え方があり、「これが正解」というのはありません。

さしあたって、それぞれの機能が他から独立するように、自分でわかりやすいように、他人に見てもらうときにもわかりやすいように、テストしやすいように、いろいろと考えて分割してみましょう。