LoginSignup
5
3

LangChain のソースコードを SOLID 原則などを思い浮かべながら眺める

Last updated at Posted at 2023-12-05

ここでは SOLID原則契約プログラミング について、 LangChain の実装の中にその要素を見出しながら、説明を試みる。

SOLID原則

SOLID原則 はオブジェクト志向プログラミング(OOP)における設計のガイドラインの一つ。以前に話したように、これは高凝集で疎結合なモジュール、今回の場合はクラスや関数の設計に関する具体的なアプローチ。

具体的には、SOLID原則には5つの要素が含まれている

  • S - Single Responsibility Principle (単一責任の原則): 変更するための理由が、一つのクラスに対して一つ以上あってはならない
  • O - Open/Closed Principle (開放/閉鎖の原則): ソフトウェアの実体(クラス、モジュール、関数など)は、拡張に対して開かれているべきであり、修正に対して閉じていなければならない
  • L - Liskov Substitution Principle (リスコフの置換原則): ある基底クラスへのポインタないし参照を扱っている関数群は、その派生クラスのオブジェクトの詳細を知らなくても扱えるようにしなければならない
  • I - Interface Segregation Principle (インターフェース分離の原則): 汎用なインターフェースが一つあるよりも、各クライアントに特化したインターフェースがたくさんあった方がよい
  • D - Dependency Inversion Principle (依存性逆転の原則): 上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも具象ではなく、抽象(インターフェースなど)に依存するべきである

今はこれらの原則の他にも、デザインパターンやポリモーフィズムなど、多様な設計指針が存在する。これらは異なる形で同じモチーフを繰り返し述べているように思える。

SOLID原則は、既知の概念を上手くまとめたものであり、その理解の容易さがその重要性を損なうわけではない。三平方の定理が中学生でも理解できるが、その重要性が変わらないのと同じ。このように、SOLID原則はプログラミングの設計における基本かつ重要なガイドラインとして、理解し適用する価値がある。

なぜ LangChain か

LangChain は、今大流行の LLM にまつわるフレームワーク。 LLM を使ったアプリケーションを簡単に作れる。

ここでは LangChain のソースコードに注目したい。というのも、以下のように注目する価値があると思うから:

  • 2022年の10月にできた、新しいものな上で、
  • Python で作られてて、
  • 多くの committer がいる。 1900 人くらい。
  • 多くの人に使われている。
  • 更新頻度が速い。

LangChain は毀誉褒貶がある。しかしながら、上の理由だけでも、その内部設計に注目してよい理由にはなっているはず。

実際、 LangChain のコードから SOLID原則 を見出すことができる。 Python における実装例を見られるのは嬉しい。

ということで、これからは LangChain のコードを引用しながら、 SOLID 原則などについて説明しようと思う。

LangChain を使った実装例

LangChain のコードを見る前に、 LangChain を使う一つのサンプルを見せる。ここから、振る舞いを理解してほしい。

以下はいわゆる RAG を実現したもの。

pip install langchain chromadb tiktoken openai
from langchain.chains import RetrievalQA
from langchain.chat_models import ChatOpenAI
from langchain.document_loaders import TextLoader
from langchain.embeddings.openai import OpenAIEmbeddings
from langchain.text_splitter import CharacterTextSplitter
from langchain.vectorstores.chroma import Chroma

loader = TextLoader("日本国憲法.txt")
documents = loader.load()
text_splitter = CharacterTextSplitter()
texts = text_splitter.split_documents(documents)
embeddings = OpenAIEmbeddings()
docsearch = Chroma.from_documents(texts, embeddings)
retriever = docsearch.as_retriever()
llm = ChatOpenAI(model_name="gpt-4", api_key="YOUR_API_KEY")
qa = RetrievalQA.from_chain_type(llm=llm, retriever=retriever)

日本国憲法.txt は以下。

質問する。

answer = qa.run("テキストにはなんと書いてある?")
print(answer)

出力

このテキストは日本の憲法に関するもので、皇室、戦争の放棄、国民の権利と義務について述べています。
皇室については、摂政の置かれ方、天皇の任命行為、国事行為、...(省略)

DIP - Dependency Inversion Principle (依存性逆転の原則)

説明

SOLID のいきなり D から説明する。というのも、 SOLID 原則ではこれが一番重要だと僕は思うから。他のやつを説明するにも、これが最初にないと説明しにくい。

DIP は Wikipedia の定義を引用すると、

上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも具象ではなく、抽象(インターフェースなど)に依存するべきである。

といったもの。

コードで示そう。

まず、これは DIP に 違反 した例。

class MySQLDatabase:
    def connect(self):
        return "MySQLデータベースに接続"

    def fetch_data(self):
        return "データを取得"

class ReportGenerator:
    def __init__(self):
        self.database = MySQLDatabase()

    def create_report(self):
        self.database.connect()
        data = self.database.fetch_data()
        return f"レポート: {data}"

# 使用例
report_generator = ReportGenerator()
print(report_generator.create_report())

クラス図にするとこのようになる。

これはやりがちな実装。 ReportGenerator の中で MySQLDatabase がインスタンス化されている。

これは問題がある。

まず ユニットテストの実装が、この時点で非常に難しいMySQLDatabase のインスタンスがちゃんと動くことを期待している。いちいち docker container 立てて、然るべきデータをいれて "ユニットテスト" をする?そんな馬鹿な。

他にも、もう一つやりがちなやつ。上と本質的には変わらないもの。

class C:
    def operation_c(self):
        return "Cの操作"

class B:
    def __init__(self):
        self.c = C()

    def operation_b(self):
        return f"Bの操作と {self.c.operation_c()}"

class A:
    def __init__(self):
        self.b = B()

    def operation_a(self):
        return f"Aの操作と {self.b.operation_b()}"

# 使用例
a = A()
print(a.operation_a())

クラス図

この2つの例は、クラス間が密結合になっている。この例では、クラス C をどうにかしたいのに、それに依存している B についても考えなきゃいけない。さらにそれに伴い B のコードを変えるということは、 A の面倒も見なければならない。

密結合だと、以下のような問題がある。

  • テストが困難
  • 拡張が困難。 MySQL を Postgres に変えようかなと思ったら、それに依存している人全員が「大丈夫かな」「影響ないかな」と思わねばならない。

これを DIP を使って書き直すとこうなる。

from abc import ABC, abstractmethod

class IDatabase(ABC):
    @abstractmethod
    def connect(self):
        pass

    @abstractmethod
    def fetch_data(self):
        pass

class MySQLDatabase(IDatabase):
    def connect(self):
        return "MySQLデータベースに接続"

    def fetch_data(self):
        return "データを取得"

class ReportGenerator:
    def __init__(self, database: IDatabase):
        self.database = database

    def create_report(self):
        self.database.connect()
        data = self.database.fetch_data()
        return f"レポート: {data}"

# 使用例
# MySQLデータベースを使用
mysql_db = MySQLDatabase()
report_generator = ReportGenerator(mysql_db)
print(report_generator.create_report())

ReportGenerator が Abstract Class として実装された IDatabase に依存している。肝心なのは、 IDatabase にしか依存していないこと。そして肝心の MySQLDatabase はその IDatabase の実装になっている。つまり、 ReportGeneratorMySQLDatabase依存していない。つまり 疎結合 である。に

これで何が嬉しいか。

例えばテスト問題は解決する。

以下のような実装をすればいい。

import pytest
from unittest.mock import MagicMock

def test_create_report():
    # IDatabaseのモックを作成
    mock_database = MagicMock(spec=IDatabase)
    mock_database.fetch_data.return_value = "テストデータ"

    # ReportGeneratorのインスタンスを作成
    report_generator = ReportGenerator(mock_database)

    # レポートを生成
    report = report_generator.create_report()

    # メソッド呼び出しを検証
    mock_database.connect.assert_called_once()
    mock_database.fetch_data.assert_called_once()

    # 生成されたレポートを検証
    assert report == "レポート: テストデータ"

テスト用の振る舞いだけ似せた Mock を準備すればよい。

また拡張も簡単になる。

class PostgreSQLDatabase(IDatabase):
    def connect(self):
        return "PostgreSQLデータベースに接続"

    def fetch_data(self):
        return "データを取得"

# PostgreSQLデータベースに切り替える場合
postgresql_db = PostgreSQLDatabase()
report_generator = ReportGenerator(postgresql_db)
print(report_generator.create_report())

もう一つの例のほうも、 DIP の原則に従ってリファクタリングしておこう。

from abc import ABC, abstractmethod

class IC(ABC):
    @abstractmethod
    def operation_c(self):
        pass

class IB(ABC):
    @abstractmethod
    def operation_b(self):
        pass

class C(IC):
    def operation_c(self):
        return "Cの操作"

class B(IB):
    def __init__(self, c: IC):
        self.c = c

    def operation_b(self):
        return f"Bの操作と {self.c.operation_c()}"

class A:
    def __init__(self, b: IB):
        self.b = b

    def operation_a(self):
        return f"Aの操作と {self.b.operation_b()}"

# 使用例
c = C()
b = B(c)
a = A(b)
print(a.operation_a())

クラス図は以下のようになる。

ちなみに、これはいわゆる インターフェース設計 とも言いかえられる。

デメリットは何か。

抽象的であるので、実装が抽象に依存しているが故、素朴に読みづらくなること。

とはいえ、これはトレードオフとして見るべきか?「読みづらいが故ユニットテストができないことを許容する」ということにはならないだろう。

DI と DI コンテナ

ここでもう一つの用語を導入する。 DI - Dependency Injection (依存性の注入) である。

DI は上のコードで言うと

mysql_db = MySQLDatabase()
report_generator = ReportGenerator(mysql_db)

c = C()
b = B(c)
a = A(b)

の部分。

上の抽象クラスに依存したクラス定義は、そのままだともちろん動かない最後には、どこかで具象クラスに依存しなければ、動かない

その依存を実際に行う作業が、まさに依存性の注入と呼ばれるもの。

ここで、もう一つの話をしなければならない。それは DI コンテナ の存在。

想像してほしい。一つのプロダクトのクラスは何十とある。それぞれ複雑に抽象クラスに依存して動いている。さて、これら何十というクラスに対して、 DI しなければならないことを。つまり DI の管理にペインが発生する。

一見して大変そうだ。それをシステムで解決する仕組みがある。それが DI コンテナ、と呼ばれるもの。

DIコンテナは、リッチなものを使えば、 DI に関する一つもコードを書くことなく DI が実現してしまう。たとえば Java の Spring Framework. DIコンテナの仕組みの頭がよくて、型が適切に設定されている限り、自分で適切な具象クラスを探索してインスタンス化してくれる。

DIコンテナを入れれば、最初にあげた DI の管理に関するペインが解消する。

すなわち、 DIP を実践するのには DI コンテナはあったほうがよい。

しかしながら、 Python だと、僕が観測した限りでは、まだ DI コンテナを行うライブラリが成熟していない。

Python の DI ツールはたくさんあるけれども、その中でも最もスターが多く、 FastAPI などにもサンプルがあったのが python-dependency-injector である。 FastAPI のドキュメントにもそれを用いた 実装例 がある。

しかしながら、最近メンテナンスされてない。以下のような不穏な issue もある。

というわけで、今のところ Python では僕は、手動で DI の管理をする。

実際、そのペインを低減するさせることはできるだろう。 DI の管理を行うのを 一つのクラスに集約させる。それ以外では DI を行わない、とする。そうすれば、責任範囲は明確。そのクラスが適切に DI をやってくれることを、うまくクラスの中で実装する、という風に責任をわければよい。何十というものが複雑とはいえ、クラスの中でうまく関数をわけたり、また最初に作るときに注意深くすれば、後からドラスティックに変える、ということもないだろう。ここで GoF の Builder パターンFactory パターン を使えばよい。

LangChain ではどうか

さてめちゃくちゃ前置きが長くなったが、 LangChain で該当の実装をみてみる。

LangChain では 全体を通して DIP が徹底されている

RetrievalQA のコードを見てみる。

class RetrievalQA(BaseRetrievalQA):
    """Chain for question-answering against an index.

    Example:
        .. code-block:: python

            from langchain.llms import OpenAI
            from langchain.chains import RetrievalQA
            from langchain.vectorstores import FAISS
            from langchain_core.vectorstores import VectorStoreRetriever
            retriever = VectorStoreRetriever(vectorstore=FAISS(...))
            retrievalQA = RetrievalQA.from_llm(llm=OpenAI(), retriever=retriever)

    """

    retriever: BaseRetriever = Field(exclude=True)

ちなみに、変数の定義の仕方奇妙に見えるかもしれないが、これは pydantic を使っているから。この pydantic についても、後ほど話をする。

この BaseRetriever は以下のような抽象クラスである。

from abc import ABC, abstractmethod
...()...
class BaseRetriever(RunnableSerializable[str, List[Document]], ABC):

Retriever のテストは以下のようなイメージ。 FakeRetriever というのをテストケースごとに作ってテストを行っている。

@pytest.fixture
def fake_retriever_v1() -> BaseRetriever:
    with pytest.warns(
        DeprecationWarning,
        match="Retrievers must implement abstract "
        "`_get_relevant_documents` method instead of `get_relevant_documents`",
    ):

        class FakeRetrieverV1(BaseRetriever):
            def get_relevant_documents(  # type: ignore[override]
                self,
                query: str,
            ) -> List[Document]:
                assert isinstance(self, FakeRetrieverV1)
                return [
                    Document(page_content=query, metadata={"uuid": "1234"}),
                ]

            async def aget_relevant_documents(  # type: ignore[override]
                self,
                query: str,
            ) -> List[Document]:
                assert isinstance(self, FakeRetrieverV1)
                return [
                    Document(
                        page_content=f"Async query {query}", metadata={"uuid": "1234"}
                    ),
                ]

        return FakeRetrieverV1()  # type: ignore[abstract]

LSP - Liskov Substitution Principle (リスコフの置換原則)

説明

名前から絶対意味を悟れない原則。 しかしながら、大したことは言っていない。 Wikipedia には以下のように説明されている。

ある基底クラスへのポインタないし参照を扱っている関数群は、その派生クラスのオブジェクトの詳細を知らなくても扱えるようにしなければならない。

別の説明では以下のようにある:

SがTのサブタイプである場合、T型のオブジェクトは、プログラムを壊すことなくS型のオブジェクトに置き換えることができなければならない。

どういうことか。僕の言葉で言い換えるならば、「親クラスを継承して子クラスを実装するならば、親クラスが定義する振る舞いに忠実であれ」ということ。

実装を見てみよう。以下は LSP に反した 実装例。

from abc import ABC, abstractmethod


class MetaA(ABC):
    @abstractmethod
    def run(self, input_data: list):
        """Run the model"""


class A(MetaA):
    def run(self, input_data: dict):
        """Run the model"""
        return input_data

    def custome_method(self):
        return "custome_method"


a = A()

上のやつは run メソッドの型の指定が間違っている。これが LSP に違反している、ということ。

LSP に違反すると、何が問題になるのか?

上の DIP を思い出してもらえば自明だろう。抽象クラスで定義した通りに具象クラスで実装が行われていないというルール違反が横行するならば、 DIP もクソもない

逆に LSP に準拠したリファクタリングにする、というのは、親クラスが定義した method の引数の型を合わせる、ということ。上の例であれば dict を list にする。

さて、 LSP を準拠することをもっと楽にする方法はあるだろうか?

ある。 Linter を使うこと。とりわけ Python であれば mypy が、その選択肢である。

mypy を使えば、以下のようにどこで LSP に違反しているのかを確かめてくれる。明確に "This violates the Liskov substitution principle" と述べてくれる。

$ mypy langchain_handson/a.py 
langchain_handson/a.py:61: error: Argument 1 of "run" is incompatible with supertype "MetaA"; supertype defines the argument type as "list[Any]"  [override]
langchain_handson/a.py:61: note: This violates the Liskov substitution principle
langchain_handson/a.py:61: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides
Found 1 error in 1 file (checked 1 source file)

vscode を使っているならば、 mypy のプラグインを入れておけばコンソールでコマンドを打つ必要もない。

LangChain ではどうか

LangChain でも mypy を使ってる。

ISP - Interface Segregation Principle (インターフェース分離の原則)

説明

Wikipedia には以下のように説明されている:

汎用なインターフェースが一つあるよりも、各クライアントに特化したインターフェースがたくさんあった方がよい

これについては、だいたい言っていることがわかるかもしれない。

コードの例を見よう。以下は ISP に 違反 している例。

class WorkerInterface:
    def work(self):
        pass

    def eat(self):
        pass

class Human(WorkerInterface):
    def work(self):
        print("人間は働く")

    def eat(self):
        print("人間は食事をする")

class Robot(WorkerInterface):
    def work(self):
        print("ロボットは働く")

    # Robot は食事をしないが、インターフェイスの一部として定義されている
    def eat(self):
        pass

このように ISP に違反すると、何が問題になるか?

  • 不必要な依存性: クラスが使用しないメソッドに依存することになり、システム全体の結合度が不必要に高くなる。これは保守性や拡張性に悪影響を与える。
  • 理解しにくい設計: 大きくて複雑なインターフェイスは、理解や使用が難しくなることがある。特に、関係のないメソッドが混在している場合、その目的や機能が明確でなくなる

DB のテーブル設計にも雰囲気が似ているかもしれない。ある条件にしか適合しないカラムがあって、そこに NULL がずっと入り続ける。そういうときは正規化したくなるよね。今回もそうである。正規化しようね、というかんじ。

これを ISP に準拠するようにリファクタリングする。

class Workable:
    def work(self):
        pass

class Eatable:
    def eat(self):
        pass

class Human(Workable, Eatable):
    def work(self):
        print("人間は働く")

    def eat(self):
        print("人間は食事をする")

class Robot(Workable):
    def work(self):
        print("ロボットは働く")

Interface レベルで分けて、それを継承する。

クラス図

LangChain ではどうか

上と全く同じ例がある。 Mix-in をうまく活用している。

Docstore と呼ばれる抽象クラスがあり、それを実装している InMemoryDocstoreWikipedia という2つのクラスがある。ここで InMemoryDocstoreadd という関数が必要なことがわかった。では、 Docstore の抽象クラスに add を定義する?でもそれだと Wikipedia クラスは無駄な add を実装しなければならない。(実装しないことは LSP に違反するしね。)
それを解決するために AddableMixin という Mix-in のクラスを実装した。 Mix-in のクラスとは、インスタンス化を意図されないクラス。 InMemoryDocstoreDocstoreAddableMixin 多重継承する。一方 Wikipedia は Docstore だけ使う。

class Docstore(ABC):
    """Interface to access to place that stores documents."""

    @abstractmethod
    def search(self, search: str) -> Union[str, Document]:
        """Search for document.

        If page exists, return the page summary, and a Document object.
        If page does not exist, return similar entries.
        """

    def delete(self, ids: List) -> None:
        """Deleting IDs from in memory dictionary."""
        raise NotImplementedError


class AddableMixin(ABC):
    """Mixin class that supports adding texts."""

    @abstractmethod
    def add(self, texts: Dict[str, Document]) -> None:
        """Add more documents."""

class InMemoryDocstore(Docstore, AddableMixin):

class Wikipedia(Docstore):

クラス図

OCP - Open/Closed Principle (開放/閉鎖の原則)

説明

まず Wikipedia の定義:

ソフトウェアの実体(クラス、モジュール、関数など)は、拡張に対して開かれているべきであり、修正に対して閉じていなければならない。

これもコードを例にする。

以下は OCP に違反している例。

class DiscountCalculator:
    def calculate(self, amount, customer_type):
        if customer_type == "standard":
            return amount * 0.9  # 10% discount
        elif customer_type == "premium":
            return amount * 0.8  # 20% discount
        # 新しい顧客タイプを追加するたびにこのメソッドを変更する必要がある

まずこれのどこが OCP に違反しているかを説明する。

OCP が述べていることは、新しい機能拡張があるときに、今存在するコードを修正しなくても良いようにする ということ。これを考えながらこのコードを読むと、新しい顧客タイプを追加するたびに、このクラスを変更する必要がある。したがって OCP に違反している。

これを OCP に準拠したリファクタリングの一例を出す。

from abc import ABC, abstractmethod

class Customer(ABC):
    @abstractmethod
    def apply_discount(self, amount):
        pass

class StandardCustomer(Customer):
    def apply_discount(self, amount):
        return amount * 0.9  # 10% discount

class PremiumCustomer(Customer):
    def apply_discount(self, amount):
        return amount * 0.8  # 20% discount

class DiscountCalculator:
    def calculate(self, amount, customer: Customer):
        return customer.apply_discount(amount)

# DI
customer = PremiumCustomer()
calculator = DiscountCalculator(customer)

この構造は DIP で述べたのと同じ。 DoscountCalculator は抽象クラス Customer にのみ依存しており、その振る舞いは具体的な振る舞いは具象クラスにまかせている

これのメリットは、既存のクラス DiscountCalculator を修正しなくてもよいということ。つまり、ここに関連するテストのやり直しとかもしなくてもいい。

これはいわゆる ポリモーフィズム とかいうやつ。

たしかに、ここに CheepCustomer という新しい customer タイプを定義するならば、特に既存のコードを変更する必要はない。故に拡張が容易い。

しかしながら、だ。この OCP については、いくつかの理由でしっくり来てない。

PremiumCustomerStandardCustomer を判断するロジックは、どこかに書かなきゃいけないだろう。以下のように。

if customer_type == "standard":
    customer = StandardCustomer()
elif customer_type == "premium":
    customer = PremiumCustomer()
...
calculator = DiscountCalculator(customer)

つまり、 DI するところにロジックが入るという話。そして DI するところは既存のコードなので、新しい CheepCustomer を入れるならばこの DI しているしてる部分を修正しなければならない。これはまた OCP 違反であろう?

また他にも、拡張、というのが予見できないものである。という前提がある。なのでイマイチしっくり来ない。

なので、僕はここは単に DIP を徹底しよう、くらいの感じで捉えている。

LangChain はどうか

vectorstore については、ここがよく現れている。

Vector Store という、文字列をベクター化したものをストアするデータベースがある。有名なものだと FAISS, ChromaDB, Pinecone など。 Elasticsearch や Redis も最近はここに対応している。

今 LangChain で対応している Vector Store はだいたい 60 から 70 くらい対応している。対応しているものがたくさんあって、しかも早く対応しているのですごい。
https://python.langchain.com/docs/integrations/vectorstores/

これはそのまま OCP の恩恵を受けているみたいだ。例えばこのような PR を見てみるとよくわかる。

これは Cassanddra の対応なのだけど、本質的には cassandra.py といったファイルの追加のみしかやってない

S - Single Responsibility Principle (単一責任の原則)

説明

SOLID の最後。

Wikipedia の定義:

変更するための理由が、一つのクラスに対して一つ以上あってはならない

言っていることは SOLID の中でも一番わかりやすい。

これはそのまま「高凝集であれ」というメッセージにほかならない。

高凝集なモジュールを作ることに苦労しているわけだが、 SRP はそれに対する一つの指針を示している。

この一つのことだけやれ、というメッセージは繰り返しいろんなところで現れる。

例えば UNIX哲学 でも以下のように語られる。

一つのことを行い、またそれをうまくやるプログラムを書け。
協調して動くプログラムを書け。
標準入出力(テキスト・ストリーム)を扱うプログラムを書け。標準入出力は普遍的インターフェースなのだ。

またリーダブルコードの中にも "One Task at a Time" というかんじで似たようなことが表現されてる。

一つのことに責任をもたせるクラスを作ろうとすると、おのずとクラスのサイズが "小さく" なることもイメージできるだろう。

つまりだ。クラスが "大きい" という臭いがしたならば、それはひょっとしたらそのクラスに多くの役割をもたせすぎているのかもしれない。

巨大なモジュールのことをモノリシックと形容することがある。

繰り返しだが、最初から上手にクラスの責任を定義できるとは限らない。

そういうときは、最初はモノリシックでもよい。 Monolithic First という用語もよく見かける。

下手に分割して、返ってクラス間の結合度を密にするくらいならば、変更が一つのクラスでとどまるように、少しクラスが巨大になることを許容しながら、まずはそのようにおく、という考え方も大丈夫。

そして、あとでリファクタリングすれば OK。

LangChain はどうか

LangChain もうまくいっているわけではない。破壊的な変更もいくつか行っている。

1年たった今、ようやく落ち着いたクラス構造が見えてきたみたいだ。そのため、そのような落ち着いた設計になったクラスを langchain-core という別のところに分離して、分離した先では滅多に更新しなくてもよいようなコードにして、 LangChain の Stable リリースというのも具体的に見据えながら号令がかかっている。

DbC - Design by Contract (契約プログラミング)

説明

SOLID原則の話は上で終わり。最後にそれとはまた関係ない DbC について話そう。

Wikipedia は端的でなくて難しい。以下がその要約:

各コンポーネント(関数、メソッド、クラスなど)が満たすべき前提条件、後条件、不変条件を明示的に指定することで、システムの正確性と信頼性を向上させるプログラミング手法

要するに、コンポーネントを使う人と提供する人で、それぞれ満たすべきお約束があるのだから、それをそれぞれ守りましょう、ということを契約という言葉で表現しているかんじ。

コードで説明する。以下は DbC に 違反 している例。

例えば、 User というクラスが年齢を表す age という属性を持っていたとする。

from dataclasses import dataclass


@dataclass
class User:
    id: int
    name: str
    age: int


u = User(id=1, name="John Doe", age=-1)

この User クラスを作った人は「age に負の数が入ってほしくないなあ」と思っているとする。

そういう意味で、上の u は不正である。なぜなら age に負の数が入っているから。これは DbC の前提条件に違反している。

DbC で述べていることの一つ(前提条件)は、「クラスを使う人は、その引数を正しく入れる責任がある」ということ。

さて、 DbC を守りたい。故に User クラスの提供者は、不正なインプットが入ってきたら、それを Validate して、その違反者に知らせたい。

それをいちいち実装する必要があるか?いやない。なぜならここに pydantic があるから。

from pydantic import BaseModel, PositiveInt, ValidationError


class User(BaseModel):
    id: int
    name: str
    age: PositiveInt


# 正しいデータを使用
try:
    user = User(id=123, name="John Doe", age=30)
    print(user)
except ValidationError as e:
    print(e)

# 不正なデータを使用(例: 年齢に文字列を渡す)
try:
    user = User(id=123, name="John Doe", age="thirty")
except ValidationError as e:
    print(e)

# 不正なデータを使用(例: 年齢に負の数を渡す)
try:
    user = User(id=1, name="John Doe", age=-1)
except ValidationError as e:
    print(e)
id=123 name='John Doe' age=30
1 validation error for User
age
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='thirty', input_type=str]
    For further information visit https://errors.pydantic.dev/2.5/v/int_parsing
1 validation error for User
age
  Input should be greater than 0 [type=greater_than, input_value=-1, input_type=int]
    For further information visit https://errors.pydantic.dev/2.5/v/greater_than

pydantic の HP を見ると transformer や fastapi でも使われている。 mypy とともに、 python に型による新たなロバストネスを実現する手段を与えてくれた。

LangChain はどうか

すべてのクラスで pydantic を使っている。正確には、継承をたどれば必ず BaseModel に行き着くようになっている。

おわりに

まとめると

  • まず DIP が本質的。 DIP をやってから他をケアしていく。
  • mypy, pydantic を使おう

ということか。ちょっと疲れたのでここで終わり。

5
3
0

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
5
3