pythonのクラスメソッドの使い方について説明する記事は大量に存在する。それらで機能を説明するために使われる例の多くは必ずしもclassmethodでなくてもよい。”なくてもよい”であればまだマシで、staticmethodやその他方法の方法で定義すべき例を挙げて説明している記事が多いと感じる。

そこで、個人的に考えるclassmethodの使い所についてまとめた。


個人的に考えるclassmethodの使い所

ズバリ「__init__で定義する以外の方法でインスタンスを定義させるメソッドを作りたいとき」だと考えている。

これ以外の場合は基本的にstaticmethodなど他のやり方をとればよい(もしくは取った方が良い)と思う。

よく説明に使われる例

Python初心者向け解説記事でよく使われる例に以下のようなコードがある。

class MyClass:
    @classmethod
    def hello_world(cls):
        return "Hello world!"


MyClass.hello_world()

'Hello world!'

もちろんこれで動作には何も問題ない。しかし、クラスメソッドを定義した際に引数として受け取っているクラス自身 cls が完全に無視されている。

このような例では、冗長性を回避する観点や意図しない動作を防止するため、第一引数にクラス自信を取らないstaticmethodとするのが正しいはずである。

class MyClass:
    @staticmethod
    def hello_world():
        return "Hello world!"


MyClass.hello_world()

'Hello world!'

もちろん動作には問題ない。

classmethodとは python」などと検索すると、上位に出てくるサイトの多くが、上記と同じような構造を使ってclassmethodを説明している。

この例を用いて説明していては、staticmethodとの違いを問われても「引数を書いておく必要があるかどうか」くらいにしか答えられない。

本質的な違いは第一引数clsを適切な形で使用するからこそ生じるのである。

最近私が使用した例

具体的に私が使用したコードを示す。

あるarray-likeなオブジェクトをもとにオフセットを踏まえた範囲を計算して保持するクラスを作成した。

# モジュールのインポート
from typing import Union, Tuple
from typing_extensions import Self

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import load_iris

class Limit:
    def __init__(
        self, *arrays: Union[np.ndarray, pd.Series, list], alpha: float = 0.05
    ):
        """Calculate the limit of the given arrays.

        Parameters
        ----------
        alpha : float, optional
            ratio of margin to the range of the given arrays, by default 0.05

        Raises
        ------
        ValueError
            At least one array is required.
        ValueError
            alpha must be greater than or equal to 0.
        """
        # check arguments
        if len(arrays) == 0:
            raise ValueError("At least one array is required.")
        if alpha < 0:
            raise ValueError("alpha must be greater than or equal to 0.")

        self.__without_margin = (
            min(np.nanmin(np.array(arr).ravel()) for arr in arrays),
            max(np.nanmax(np.array(arr).ravel()) for arr in arrays),
        )

        margin = (self.__without_margin[1] - self.__without_margin[0]) * alpha
        self.__with_margin = (
            self.__without_margin[0] - margin,
            self.__without_margin[1] + margin,
        )

    @classmethod
    def from_with_margin(
        cls, with_margin: Tuple[float, float], alpha: float = 0.05
    ) -> Self:
        """Calculate the limit from the given limit with_margin.

        Parameters
        ----------
        with_margin : Tuple[float, float]
            limit with margin
        alpha : float, optional
            ratio of margin to the range of the given arrays, by default 0.05

        Returns
        -------
        Self
            Limit object

        Raises
        ------
        ValueError
            with_margin must be a tuple of length 2.
        """
        # check with_margin
        if len(with_margin) != 2:
            raise ValueError("with_margin must be a tuple of length 2.")
        return cls(
            [
                ((1 + alpha) * with_margin[0] + alpha * with_margin[1])
                / (2 * alpha + 1),
                (alpha * with_margin[0] + (1 + alpha) * with_margin[1])
                / (2 * alpha + 1),
            ],
            alpha=alpha,
        )

    @property
    def without_margin(self) -> Tuple[float, float]:
        return self.__without_margin

    @without_margin.setter
    def without_margin(self, value):
        raise AttributeError("Cannot set `without_margin` attribute.")

    @property
    def with_margin(self) -> Tuple[float, float]:
        return self.__with_margin

    @with_margin.setter
    def with_margin(self, value):
        raise AttributeError("Cannot set `with_margin` attribute.")

これの普通?の使い方には、縦と横で範囲を揃えたいときにデータの範囲を計算するなどがある。

iris = load_iris(as_frame=True)
df_iris = iris.data

fig, ax = plt.subplots(facecolor="w")

x = df_iris["sepal length (cm)"]
y = df_iris["sepal width (cm)"]

ax.scatter(x, y, c=iris.target, cmap="viridis")

ax.set_xlabel("sepal length (cm)")
ax.set_ylabel("sepal width (cm)")

lim = Limit(x, y)
ax.set_xlim(lim.with_margin)
ax.set_ylim(lim.with_margin)

ax.set_aspect("equal", adjustable="box")

fig.tight_layout()

2023-02-13-python-classmethod_12_0.png

classmethodで定義した関数の使い所は、さきほどのようにデータの範囲から外側にマージンをとるのとは逆にmatplotlibが自動で設定した範囲からマージンを内側にとるに使う。

たとえば、textをグラフ内に書き込みたいときの位置を求めたいとき。

fig, ax = plt.subplots(facecolor="w")

x = df_iris["sepal length (cm)"]
y = df_iris["sepal width (cm)"]

ax.scatter(x, y, c=iris.target, cmap="viridis")

ax.set_xlabel("sepal length (cm)")
ax.set_ylabel("sepal width (cm)")

lim_x = Limit.from_with_margin(ax.get_xlim())
lim_y = Limit.from_with_margin(ax.get_ylim())

ax.set_xlim(lim_x.with_margin)
ax.set_ylim(lim_y.with_margin)

ax.set_aspect(1.0 / ax.get_data_ratio(), adjustable="box")

ax.text(
    lim_x.without_margin[1],
    lim_y.without_margin[1],
    "Here is an example.",
    va="top",
    ha="right",
)
fig.tight_layout()

2023-02-13-python-classmethod_14_0.png

こうすることで適度に端から離れた場所にテキストボックスを設置することができる。

実際どのように使われているのか

有名な例でいえば、pandasのpandas.DataFrame.from_dictメソッドがある。

辞書からDataFrameを定義する場合、このメソッドを用いて以下のような書き方ができる。

df2 = pd.DataFrame.from_dict(d)
df2

col1 col2
0 1 6
1 2 7
2 3 8
3 4 9
4 5 10

ソースコードを辿ってもらえばわかるが、これはclassmethodを用いて定義されている。

最終的にcls(...)をreturnしており、__init__()を別の形で呼び出すために定義されていることがわかる。

まとめ

以上、自作のコード、有名なコードの例を挙げつつ、私の考えるclassmethodの使い所についてまとめた。

参考にならんことを。