【Python実践自作問題集】No.7 不正な値を許すな

# 1. はじめに

この問題集は、Pythonの基礎を習得した後、次の段階へ進みたい人のサポートをすることが目的です。
また、競技プログラミングとは異なり、複雑なアルゴリズムの問題ではなく「**可読性の最大化**」に焦点を当てた問題が中心となります。

[目次](https://qiita.com/__Lily__/items/6f70cd6ae0e80593f09d)
前の問題：[型に厳密なデータクラス3](https://qiita.com/__Lily__/items/36f527a357aa63b2520d)
次の問題：[ネストされた辞書](https://qiita.com/__Lily__/items/5e721346f62147cc26e5)


# 2. 問題

```python
"""
No.7 不正な値を許すな

ある店では、果物、野菜、穀類の商品コードを次のようなフォーマットで管理しています。

例：F0001-02, V0012-01, G0003-03

* 文字列の先頭が商品のカテゴリを表す。
    F: 果物
    V: 野菜
    G: 穀類
* 続く4桁の数字が大分類番号を表す。
* 続く2桁の数字が小分類番号を表す。
* 大分類と小分類の間には'-'が入る。

次の要件を満たすように、商品コードに関する処理を構築してください。

* 与えられた文字列が、上記フォーマットに適合しているかチェックする機能を実装する。
    適合していない場合、エラーを発生させる。
"""
```


# 3. 解答例

```python:__init__でチェックver
import re
from enum import StrEnum


class ProductCategory(StrEnum):
    FRUIT = 'F'
    VEGETABLE = 'V'
    GRAIN = 'G'


class ProductCode(str):

    def __init__(self, product_code: str) -> None:
        if (category := self[0]) not in ProductCategory:
            raise ValueError(f'invalid product category: {category}')
        if not re.fullmatch('[0-9]{4}-[0-9]{2}', self[1:]):
            raise ValueError(f'invalid product code format: {self}')
        super().__init__()
```

```python:__new__でチェックver
import re
from enum import StrEnum


class ProductCategory(StrEnum):
    FRUIT = 'F'
    VEGETABLE = 'V'
    GRAIN = 'G'


class ProductCode(str):

    def __new__[T](cls: type[T], product_code: str) -> T:
        if (category := product_code[0]) not in ProductCategory:
            raise ValueError(f'invalid product category: {category}')
        if not re.fullmatch('[0-9]{4}-[0-9]{2}', product_code[1:]):
            raise ValueError(f'invalid product code format: {product_code}')
        return super().__new__(cls, product_code)
```


# 4. 採点基準

* 与えられた商品コードのフォーマットが適切でない場合、エラーが発生すること
* 機能の拡張に対して柔軟に対応できること（開放閉鎖原則）
* 不必要な機能を実装していないこと（YAGNI原則）


# 5. 解説

## 5.1 値オブジェクト(Value Object)

商品コードは文字列です。
では、商品コードを単純に`str`型で表現するとどうなるでしょうか？
試しに商品コードのフォーマットをチェックする`validate_format`関数を定義してみましょう。

```python
def validate_format(product_code: str) -> None:
```

実際のシステムでは`str`型の変数なんてありふれています。
ユーザー名を表す`user_name`変数を指定されてしまうかもしれません。

```python
validate_format(user_name)  # 型チェックを通過する
```

もちろん、関数名を工夫することである程度の予防はできるでしょう。
しかしこれも完璧ではない上、ずらずらと関数が並んでいくことになります。

```python
def validate_product_code_format(product_code: str) -> None: ...
def validate_user_name_format(user_name: str) -> None: ...
```

そこで値オブジェクトの考え方を導入します。
ざっくり言うと「商品コードを単純な`str`型で扱うのではなく、新たに『商品コードクラス』を定義して扱う」という手法です。
ただし、値オブジェクトは通常のクラスとは異なる特徴を持ちます。
いくつかありますが、今回は「不正な値を許さない」「値の変更ができない」という特徴に焦点を当てたいと思います。

[設計要件をギッチギチに詰めたValueObjectで低凝集クラスを爆殺する - Qiita](https://qiita.com/MinoDriven/items/5e69d9bd028aa350e2c4)
[【DDD】値オブジェクト - Value Object - Zenn](https://zenn.dev/chida/articles/aa2a63cdf2eb52)
[3分でわかる値オブジェクト - Qiita](https://qiita.com/wanko5296/items/8b470934cdc14f869a91)

値オブジェクトの定義方法はいくつかありますが、今回は`str`型を継承する方法を採用します。
以下のようにクラスを定義し、商品コードとして単純な文字列ではなく`ProductCode`クラスのインスタンスを使用します。

```python
class ProductCode(str): ...

product_code = 'F0001-02'  # こうではなく……
product_code = ProductCode('F0001-02')  # こう！
```

この`ProductCode`クラスにフォーマットのチェック機能を実装します。
インスタンス作成時にチェックしてほしいので、`__init__`メソッドの中に実装します。

```python:__init__に記述
class ProductCode(str):

    def __init__(self, product_code: str) -> None:
        # ここでフォーマットチェック
        super().__init__()
```

あるいは、`__new__`メソッドの中に実装することもできます。

```python:__new__に記述
class ProductCode(str):

    def __new__[T](cls: type[T], product_code: str) -> T:
        # ここでフォーマットチェック
        return super().__new__(cls, product_code)
```

[python \_\_init\_\_()と__new__()の違い - note.com](https://note.com/marketscience/n/n617295600cd5)
[Pythonの特殊メソッド__new__とは - hatenablog.com](https://okiyasi.hatenablog.com/entry/2020/07/25/022838)

このようにして作成した商品コードは、必ず正しいフォーマットであるという保証がされています。
なぜなら、不正なフォーマットで商品コードを作成した時点でエラーになるからです。

```python
ProductCode('F0001-02')  # OK
ProductCode('X0001-02')  # エラー
```

しかし、作成時のチェックをいくら強固にしたところで、後から値を書き換えられてしまっては意味がありません。

```python
product_code = ProductCode('F0001-02')
product_code[0] = 'X'  # 'X0001-02'になってしまう？
```

ですが心配はいりません。
`ProductCode`クラスは`str`クラスを継承しています。
そして`str`クラスはImmutable（変更不能）ですから、`product_code[0] = 'X'`といった操作はエラーとなり実行できません。

## 5.2 フォーマットのチェック

フォーマットのチェック処理について詳しく見ていきます。
初めに言いますが、これに関しては明確な正解というものはありません。
いくつもの案を考え、それらを比較検討しながら適切であると判断したのなら、それは一つの正解となるでしょう。

まず、要件通り（そして、それ以外を考えない）のフォーマットを正規表現で表すと次のようになります。

```text
[FVG][0-9]{4}-[0-9]{2}
```

* `[FVG]`はF, V, Gのいずれか1文字
* `[0-9]{4}`は0から9の数字から成る4文字
* `-`はそのまま-
* `[0-9]{2}`は0から9の数字から成る2文字

なお、`\d`も数字1文字を表す正規表現ですが、全角数字を含んでしまう場合があるので使用を避けました。

[数字の正規表現(\d)の環境による動作の違い - sun-san-tech.com](https://sun-san-tech.com/regex/509/)

与えられた文字列がこのフォーマットに一致していない場合にエラーを発生させるコードは次のようになります。

```python
import re

product_code = 'F0001-02'
if not re.fullmatch('[FVG][0-9]{4}-[0-9]{2}', product_code):
    raise ValueError(f'invalid product code format: {product_code}')
```

これは一つの正解になり得るでしょう。
しかし、商品カテゴリが追加されたり、記号が変更されたらどうでしょう？
毎回正規表現を見直すというのは手間がかかります。
そこで、商品カテゴリを列挙体で管理し、チェック処理も分類番号とは分離することにします。

```python
class ProductCategory(StrEnum):
    FRUIT = 'F'
    VEGETABLE = 'V'
    GRAIN = 'G'


product_code = 'F0001-02'
if (category := product_code[0]) not in ProductCategory:
    raise ValueError(f'invalid product category: {category}')
if not re.fullmatch('[0-9]{4}-[0-9]{2}', product_code[1:]):
    raise ValueError(f'invalid product code format: {product_code}')
```

これなら商品カテゴリが追加されても、記号が変更されてもチェック処理の変更は不要です。
このように、機能を拡張しても既存コードの変更が最小限で済むように設計しようというのは、SOLID原則の一つ「**開放閉鎖原則**」として挙げられています。

[開放/閉鎖原則(OCP)の学習メモ - hatenablog.com](https://hama-engineer.hatenablog.com/entry/2024/05/03/135936)

さて、ここからが問題。
商品カテゴリの追加を想定するなら、分類番号の桁数が増えることも想定すべきなのか？
分類番号が2階層ではなく3階層になることも想定すべきなのか？
……等々、発生し得る変更というのはたくさんあります。
じゃあ全部の変更を想定しなければいけないのか、というとそんなことはなくて、今不必要なものは実装すべきでありません。
これを「**YAGNI原則**」と言います。

[知っておきたい！YAGNIの法則 - Qiita](https://qiita.com/sesame525/items/fb2cfb23d0d7ef593014)

そして混乱しがちなのが、開放閉鎖原則は「将来の変更を見据えて設計しろ」、YAGNI原則は「今不必要なものは実装するな」と、一見矛盾したことを主張しているように見えることです。

[YAGNI原則の誤解に注意 - zenn](https://zenn.dev/miya_tech/articles/d53057f50e53ff)

[【SIer新人向け】研修では教えてくれないノウハウ集 - Qiita](https://qiita.com/old-stone/items/6221824566116710453f#yagniyou-aint-gonna-need-it--%E3%81%9D%E3%82%8C%E3%81%AF%E3%81%8D%E3%81%A3%E3%81%A8%E5%BF%85%E8%A6%81%E3%81%AB%E3%81%AA%E3%82%89%E3%81%AA%E3%81%84)

まず前提として、どちらの原則も（そして、他の原則と呼ばれる主張たちも）あらゆる場面に適用できる銀の弾丸ではありません。
それぞれに適用すべき文脈、適用すべきでない文脈があります。
基本的には、近々発生する可能性が高い変更については開放閉鎖原則を優先するのが良いでしょう。
例えば小分類番号が00-99まで既に埋まっているという追加情報があれば、分類番号の桁数増加を見据えた設計にするのが適しています。
一方、発生する可能性が低い変更についてはTAGNI原則を優先するのが良いでしょう。
現状2階層の分類でうまく回っているなら、分類階層の増加は想定しなくてよいです。

では、発生する可能性による判断ができないときはどうすればよいでしょうか？
これに関して明確な指針はありませんが、

* 実装によるメリットが他にもある
* 実装コストが低い
* 保守コストが低い

などを考えるのが良いでしょう。

例えば今回の解答例では、商品カテゴリを列挙体として定義しました。
これは商品カテゴリの追加に対応しやすくする（開放閉鎖原則）というだけでなく、

* F/V/Gという単純な文字に説明を付けられる
* 商品カテゴリの記号は別の場所でも使うだろうという想定
* 実装コストが低い
* 保守コストが低い（むしろ下がる）

という考察から、YAGNI原則よりも優先されるだろうと判断したためです。
一方、分類番号の変更に対応しなかったのは実装コストの高さと、実装した後の保守コストの増加によるものが大きいです。

このように「どこまでを想定して実装するか」というのは非常に難しい話ですが、実務では頻繁に発生します。
ひたすら考え抜き、最善と判断した一手を打ちましょう。


# 6. テストコード

```python
product_codes = [
    'F0001-02',
    'V0012-01',
    'G0003-03',
    'X0001-02',
    'F000a-02',
    'F001-02',
    'F00001-02',
    'F000102',
    'F0001-0b',
    'F0001-2',
    'F0001-002',
]
for product_code in product_codes:
    try:
        _ = ProductCode(product_code)
    except ValueError as e:
        print(f'[{product_code}]{e}')
    else:
        print(f'[{product_code}]error not occurred')
```

```text:実行結果（__init__でチェックver）
[F0001-02]error not occurred
[V0012-01]error not occurred
[G0003-03]error not occurred
[X0001-02]invalid product category: X
[F000a-02]invalid product code format: F000a-02
[F001-02]invalid product code format: F001-02
[F00001-02]invalid product code format: F00001-02
[F000102]invalid product code format: F000102
[F0001-0b]invalid product code format: F0001-0b
[F0001-2]invalid product code format: F0001-2
[F0001-002]invalid product code format: F0001-002
```

```text:実行結果（__new__でチェックver）
[F0001-02]error not occurred
[V0012-01]error not occurred
[G0003-03]error not occurred
[X0001-02]invalid product category: X
[F000a-02]invalid product code format: F000a-02
[F001-02]invalid product code format: F001-02
[F00001-02]invalid product code format: F00001-02
[F000102]invalid product code format: F000102
[F0001-0b]invalid product code format: F0001-0b
[F0001-2]invalid product code format: F0001-2
[F0001-002]invalid product code format: F0001-002
```