jsからpythonに翻訳する過程で気づいた__new__の使いみち(追記:ダメだった)

github.com

http://pod.hatenablog.com/entry/2017/09/14/020740気づいたのだけれど。 jsのちょっとした記述をpythonに直す時に、今まであまり使わなかった__new__()が使える箇所があるかもしれないと思った。

具体的な話

例えば、以下の様なコードがあるとする。これはnpmで使われているsemverのコードを簡略化したものなのだけれど。これをpythonのコードに翻訳したい。

function Range(range, loose) {
  if (range instanceof Range) {
    if (range.loose === loose) {
      return range; // b.
    } else {
      return new Range(range.range, loose)
    }
  }

  if (!(this instanceof Range))
    return new Range(range, loose); // c.

  // d.
  this.loose = loose;
  this.raw = range;
  // do something
}

上のコードでやっていることは以下の様なこと

  • a. Rangeの引数には,rangeとlooseが与えられる
  • b. rangeがRangeのインスタンスで使いまわせそうだったら、rangeをそのまま返す
  • c. 関数として呼ばれた場合にも、適切にオブジェクトが作られるようにする
  • d. 通常のコンストラクタとしての利用

b.flyweight的な感じだし。c.はjs固有の事情。

js固有の事情のおさらい

そういえば、と思いだしたけれど。pythonではクラスはオブジェクトを生成するファクトリー関数とみなすことができて、オブジェクトの生成は単にクラスを関数の実行と同様に呼び出すだけだけれど。jsでは関数をオブジェクトのコンストラクターとして利用する場合と通常の通りに関数として呼び出す場合の2種類の方法がある。

newについては以下の様な形。これはd.の経路を辿り、thisはrange object。

const r = new Range(">=1.2.0", true);

一方で関数呼び出しのように読んでしまった場合には、callerはglobalになる。これはc.の経路をたどる。thisはRangeのインスタンスではないので。

const r = Range(">=1.2.0", true);

オブジェクトを使いまわしたいときは、b.の経路をたどる。あんまり最近見ない気がするけれど。

const r = Range(Range(">=1.2.0", true), true);

過去の対応

過去の対応、というか現時点でのpythonでは、関数とクラスに分けていた。以下のような感じ。

class Range:
    def __init__(self, range_, loose):
        self.range = range_
        self.loose = loose

def make_range(range_, loose):
    if isinstance(range_, Range):
        if range_.loose == loose:
            return ranse_
        else:
            Range(range_.range, loose)
    return Range(range_, loose)

js固有のコードは要らないので消している。__init__()の段階で既にオブジェクトのインスタンスが生成済みなので困るということでmake_range()という関数を作り、常にこの関数を経由してオブジェクトを生成するように書いていた。悩ましいのはそれを強制する方法が全く存在しないこと。

実のところ

実のところ、オブジェクトの生成前のフックというのは、__new__()そのものなのでこれを使ってあげれば良い。 こうかけば良かったことに気づいた。

class Range:
    def __new__(cls, range_, loose):
        if isinstance(range_, Range):
            if range_.loose == loose:
                return range_
            else:
                return Range(range_.range, loose)
        return super().__new__(cls)

    def __init__(self, range_, loose):
        self.range = range_
        self.loose = loose

オブジェクトの生成方法が1つだけになるのでこちらのほうが良さそう。

追記:この方法はダメです

この方法はダメです。 なぜダメかと言うと、まず、__new__()でオブジェクトが生成されたあと自分自身のクラスと同じクラスのオブジェクトが返された場合に必ず__init__()が呼ばれます。これがまず無駄だし気持ち悪い。その上、self.rangeがRangeオブジェクトになってしまいます。

そして、渡されたrangeがRangeオブジェクトだった場合のところでも、結局、__init__()で渡されたrangeが使われてしまうので、やっぱりself.rangeがRangeオブジェクトになってしまいます。

追記2:ムキになって対応しようとしてみた

ムキになって対応してみようとした結果。デコレーターはisinstanceを壊すからだめだし。メタクラスでどうにかできることはわかっているけれど。どう考えてもオーバースペックな感じ。

class RangeMeta(type):
    def __call__(cls, range_, loose):
        if isinstance(range_, cls):
            if range_.loose == loose:
                return range_
            else:
                return cls(range_.range, loose)
        return super().__call__(range_, loose)


class Range(metaclass=RangeMeta):
    def __init__(self, range_, loose):
        self.range = range_
        self.loose = loose


r = Range(">=1.2.0", True)
print(r.range)
r2 = Range(r, True)
print(r2.range)
r3 = Range(r, False)
print(r3.range)
print(isinstance(r3, Range))