フォルダの監視プログラムを例にして関数化とクラス化について書きたかった

# はじめに
- 勢いで書いて自分の中で納得感が足りないが、それでもせっかく書いたので投稿。
- 「関数化するといい」「クラス化するといい」「継承するといい」みたいな話で、抽象的すぎたりシンプルすぎて今ひとつピンとこないことがあったので、もう少しサンプルを複雑にすればそのへんの嬉しさが伝わりやすいかもしれないと考えて書いてみた。
- 「同じ処理を複数使う」「状態を持つ」「ある程度身近そうな題材」というところでファイル・フォルに変更があることを監視して通知するプログラムを採用した。
- コード内の変数はできるだけ多めにアノテーションで型を入れて分かりやすくなるようにしている。

## 関数・クラスなしコード
- 初期状態のメイン関数にすべての処理が書かれている状態のプログラム。
- inputでファイルまたはフォルダのパスを受け取り、そのファイル、またはそのフォルダ配下のすべてのファイル・フォルダを監視対象とする。
  - input後のstripはWindowsでのパスのコピーなどで前後に「"」がある場合もパスとして扱えるようにするために行っている。
- 動きはするが、同じ処理が複数回出てくる、登場する変数が多すぎるなどの問題がある。

```py: 関数・クラスなしコード
import time
from datetime import datetime
from itertools import cycle
from pathlib import Path


def main() -> None:
    path: str = input("file or directory>").strip('"')
    interval: int = 10
    if not path:
        return
    target: Path = Path(path)
    # 監視対象フォルダ・ファイルの情報取得
    if target.is_file():
        files: dict[Path, float] = {target: target.stat().st_mtime}
        dirs: set[Path] = set()
    elif target.is_dir():
        files: dict[Path, float] = {}
        dirs: set[Path] = set()
        for p in target.glob("**/*"):
            if p.is_file():
                files[p] = p.stat().st_mtime
            elif p.is_dir():
                dirs.add(p)
    else:
        files: dict[Path, float] = {}
        dirs: set[Path] = set()
    for i in cycle(range(1, 5)):
        time.sleep(interval)
        # 監視対象フォルダ・ファイルの情報再取得
        if target.is_file():
            current_files: dict[Path, float] = {target: target.stat().st_mtime}
            current_dirs: set[Path] = set()
        elif target.is_dir():
            current_files: dict[Path, float] = {}
            current_temp_dirs: set[Path] = set()
            for p in target.glob("**/*"):
                if p.is_file():
                    current_files[p] = p.stat().st_mtime
                elif p.is_dir():
                    current_temp_dirs.add(p)
            current_dirs: set[Path] = set(current_temp_dirs)
        else:
            current_files: dict[Path, float] = {}
            current_dirs: set[Path] = set()
        # 差分の取得
        added_files: dict[Path, datetime] = {
            p: datetime.fromtimestamp(p.stat().st_mtime) for p in current_files.keys() - files.keys()
        }
        removed_files: set[Path] = files.keys() - current_files.keys()
        modified_files: dict[Path, datetime] = {
            k: datetime.fromtimestamp(k.stat().st_mtime)
            for k, v in current_files.items()
            if k in files and files[k] != v
        }
        added_dirs: dict[Path, datetime] = {
            p: datetime.fromtimestamp(p.stat().st_mtime) for p in current_dirs - dirs
        }
        removed_dirs: set[Path] = dirs - current_dirs
        # 最後に確認した状態を記録
        files: dict[Path, float] = current_files
        dirs: set[Path] = current_dirs
        # 追加されたファイルがあったら追加時間と一緒に出力
        if added_files:
            print(
                "added files",
                *(f"{p}: {dt:%Y-%m-d %H:%M:%S}" for p, dt in sorted(added_files.items()),
                sep="\n",
            )
        # 削除されたファイルがあったら出力
        if removed_files:
            print("removed files", *sorted(removed_files), sep="\n")
        # 変更のあったファイルがあったら変更時間と一緒に出力
        if modified_files:
            print(
                "modified files",
                *(f"{p}: {dt:%Y-%m-%d %H:%M:%S}" for p, dt in sorted(modified_files.items())),
                sep="\n",
            )
        # 追加されたフォルダがあったら追加時間と一緒に出力
        if added_dirs:
            print(
                "added dirs",
                *(f"{p}: {dt:%Y-%m-d %H:%M:%S}" for p, dt in sorted(added_dirs.items()),
                sep="\n",
            )
        # 削除されたフォルダがあったら出力
        if removed_dirs:
            print("removed dirs", *sorted(removed_dirs), sep="\n")
        # どれかに差分があったら終了確認を行う
        if any((i == 5, added_files, removed_files, modified_files, added_dirs, removed_files)):
            if input("----" * 10 + "\ninput any text then exit>"):
                break
```

## 繰り返し処理の関数化
- ファイル・フォルダの状態を取得するための処理が同じなので、関数化。
- 出力部分が似たような感じなので関数化。
  - dictとsetで別に関数を作ってもいいが、今回は似たような処理なので中で分岐する形で実装させた。
- 重複はなくなったが、まだメイン関数で管理する変数が多く、次のループのために変数を記録している部分が微妙。
  - 特に「○○_files」や「○○_dirs」のような似たような名前の変数が大量にあるのでこれをまとめたい。
  - 差分用の「○○_files」や「○○_dirs」は元がdictとsetでそのままだと共通化できないので今回は共通化していない。

```py
import time
from datetime import datetime
from itertools import cycle
from pathlib import Path

# ファイル・フォルダの情報取得
def get_files_and_dirs(root: Path) -> tuple[dict[Path, float], set[Path]]:
    if root.is_file():
        files = {root: root.stat().st_mtime}
        dirs: set[Path] = set()
    elif root.is_dir():
        files = {}
        temp_dirs = set()
        for p in root.glob("**/*"):
            if p.is_file():
                files[p] = p.stat().st_mtime
            elif p.is_dir():
                temp_dirs.add(p)
        dirs = set(temp_dirs)
    else:
        files = {}
        dirs = set()
    return files, dirs

# ファイル・フォルダ情報の出力
def print_paths(title: str, paths: dict[Path, datetime] | set[Path]) -> None:
    match paths:
        case dict():
            print(title, *(f"{p}: {dt:%Y-%m-%d %H:%M:%S}" for p, dt in sorted(paths.items())), sep="\n")
        case set():
            print(title, *sorted(paths), sep="\n")


def main() -> None:
    path: str = input("file or directory>").strip('"')
    interval: int = 10
    if not path:
        return
    target: Path = Path(path)
    # 監視対象フォルダ・ファイルの情報取得
    files, dirs = get_files_and_dirs(target)
    for i in cycle(range(1, 5)):
        time.sleep(interval)
        # 監視対象フォルダ・ファイルの情報再取得
        current_files, current_dirs = get_files_and_dirs(target)
        # 差分の取得
        added_files: set[Path] = current_files.keys() - files.keys()
        removed_files: set[Path] = files.keys() - current_files.keys()
        modified_files: dict[Path, datetime] = {
            k: datetime.fromtimestamp(k.stat().st_mtime)
            for k, v in current_files.items()
            if k in files and files[k] != v
        }
        added_dirs: set[Path] = current_dirs - dirs
        removed_dirs: set[Path] = dirs - current_dirs
        # 最後に確認した状態を記録
        files: dict[Path, float] = current_files
        dirs: set[Path] = current_dirs
        # 追加されたファイルがあったら追加時間と一緒に出力
        if added_files:
            print_paths("added files", added_files)
        # 削除されたファイルがあったら出力
        if removed_files:
            print_paths("removed files", removed_files)
        # 変更のあったファイルがあったら変更時間と一緒に出力
        if modified_files:
            print_paths("modified files", modified_files)
        # 追加されたフォルダがあったら追加時間と一緒に出力
        if added_dirs:
            print_paths("added dirs", added_dirs)
        # 削除されたフォルダがあったら出力
        if removed_dirs:
            print_paths("removed dirs", removed_dirs))
        # どれかに差分があったら終了確認を行う
        if any((i == 5, added_files, removed_files, modified_files, added_dirs, removed_files)):
            if input("----" * 10 + "\ninput any text then exit>"):
                break
```

## 最後の状態の記録と現在の状態の取得をクラス化
- 差分クラスを作成。
  - 差分クラスは差分を持っているかどうかをクラスのboolの判定に使うことや、出力に関する部分は同じ仕組みで実装できるので共通クラスを作成して、両方に継承させる。
  - ファイルとフォルダで元データが違うのでコンストラクタの引数と内容は個別に実装する。
- 監視クラスを作成。
  - 作成時に初回のファイル状態を取得を行わせる。
  - watchメソッドは新たにファイル状態を取得し、前回の内容と比較して差分を返し、今回取得した状態を前回の状態として記録する。
  - 前回の状態をクラスの変数として持っているので、メインロジックから次のループのための変数というそのループ内に関係ない処理が消える。
- 初回の情報取得や差分取得処理がWatcherクラスで完結するのでメイン関数の処理がかなりスッキリする。
- 出力の機能も差分クラスが持っているのでコメントを除いたメイン関数は10数行に収まった。

```py
# 関数部分は↑と同じなので省略

# 差分共通機能
class Diff:
    def __bool__(self) -> bool:
        return any(vars(self).values())

    def print(self) -> None:
        for k, v in vars(self).items():
            if v:
                name = type(self).__name__.removesuffix("Diff").lower()
                print_paths(f"{k} {name}", v)


# 共通機能を継承し、ファイルの差分を扱うためのクラス
class FilesDiff(Diff):
    def __init__(self, old_files: dict[Path, float], new_files: dict[Path, float]) -> None:
        self.added: dict[Path, datetime] = {p: datetime.fromtimestamp(p.stat().st_mtime) for p in sorted(new_files.keys() - old_files.keys())}
        self.removed: set[Path] = set(old_files.keys() - new_files.keys())
        self.modified: dict[Path, datetime] = {
            k: datetime.fromtimestamp(k.stat().st_mtime)
            for k, v in new_files.items()
            if k in old_files and old_files[k] != v
        }


# 共通機能を継承し、フォルダの差分を扱うためのクラス
class DirsDiff(Diff):
    def __init__(self, old_dirs: set[Path], new_dirs: set[Path]) -> None:
        self.added: dict[Path, datetime] = {
            p: datetime.fromtimestamp(p.stat().st_mtime) for p in new_dirs - old_dirs
        }
        self.removed: set[Path] = set(old_dirs - new_dirs)


# ファイル・フォルダの上の記録と差分の作成を行うためのクラス
class Watcher:
    def __init__(self, target: str | Path) -> None:
        self.target = Path(target)
        self.files, self.dirs = get_files_and_dirs(self.target)

    def watch(self) -> tuple[FilesDiff, DirsDiff]:
        new_files, new_dirs = get_files_and_dirs(self.target)
        files_diff = FilesDiff(self.files, new_files)
        dirs_diff = DirsDiff(self.dirs, new_dirs)
        self.files = new_files
        self.dirs = new_dirs
        return (files_diff, dirs_diff)


def main():
    path = input("file or directory>").strip('"')
    interval = 10
    if not path:
        return
    # 監視対象フォルダ・ファイルの情報取得
    watcher = Watcher(path)
    for i in cycle(range(1, 5)):
        time.sleep(interval)
        # 監視対象フォルダ・ファイルの情報再取得
        # 差分の取得
        # 最後に確認した状態を記録
        files_diff, dirs_diff = watcher.watch()
        # 追加されたファイルがあったら追加時間と一緒に出力
        # 削除されたファイルがあったら出力
        # 変更のあったファイルがあったら変更時間と一緒に出力
        files_diff.print()
        # 追加されたフォルダがあったら追加時間と一緒に出力
        # 削除されたフォルダがあったら出力
        dirs_diff.print()
        # どれかに差分があった場合と5ループに1回終了確認を行う
        if any((i == 5, files_diff, dirs_diff)):
            if input("----" * 10 + "\ninput any text then exit>"):
                break
```

## 最後に
- 同じような処理は関数としてまとめて名前をつけると使い回せるし、処理の塊に呼び名がつくことで分かりやすくなる。
  - ファイル取得や出力部分を関数化した。
- 決まった形の処理をするものや状態を持つものをクラスとしてまとめる。
  - 比較のために前回と今回の情報を集めて、保持する部分をWatcherとしてクラス化。
  - 比較の方法と表示を行う部分Diffとしてクラス化。
  - クラス化するとクラス名を考える必要が出てくるので、このまとまりにはどういう名前をつけるかを考える必要が出てくるし、そのために一連の処理の呼び方を考えることになるので処理の理解が深まるのも利点。
- 処理を関数にまとめた後に、さらにクラスにまとめることになる場合もある。
  - 今回はやらなかったが、get_files_and_dirsはやprint_pathsはそれぞれ使っているクラスにメソッドとして内部に組み込んでもいい。 
  - 処理がクラスを中心にまとまっていくとメイン関数以外は全部クラスで、メイン関数は用意されたクラスを初期化して処理を呼び出すだけみたいな内容になることもある。
- 動けばいいのときは別に関数とかクラス化に慣れていないなら無理にやる必要はないと思う。