Что такое чистый, питонский способ иметь несколько конструкторов в Python?

Я не могу найти окончательный ответ на это. AFAIK, вы не можете иметь несколько __init__ функции в классе Python. Так как мне решить эту проблему?

Предположим, у меня есть класс под названием Cheese с number_of_holes имущество. Как я могу иметь два способа создания сырных объектов...

  1. тот, который занимает несколько отверстий, как это: parmesan = Cheese(num_holes = 15)
  2. и тот, который не принимает аргументов и просто рандомизирует number_of_holes имущество: gouda = Cheese()

Я могу придумать только один способ сделать это, но это кажется немного неуклюжим:

class Cheese():
    def __init__(self, num_holes = 0):
        if (num_holes == 0):
            # randomize number_of_holes
        else:
            number_of_holes = num_holes

Что ты говоришь? Есть ли другой способ?

17 ответов

Решение

На самом деле None намного лучше для "магических" значений:

class Cheese():
    def __init__(self, num_holes = None):
        if num_holes is None:
            ...

Теперь, если вам нужна полная свобода добавления дополнительных параметров:

class Cheese():
    def __init__(self, *args, **kwargs):
        #args -- tuple of anonymous arguments
        #kwargs -- dictionary of named arguments
        self.num_holes = kwargs.get('num_holes',random_holes())

Чтобы лучше объяснить концепцию *args а также **kwargs (вы можете изменить эти имена):

def f(*args, **kwargs):
   print 'args: ', args, ' kwargs: ', kwargs

>>> f('a')
args:  ('a',)  kwargs:  {}
>>> f(ar='a')
args:  ()  kwargs:  {'ar': 'a'}
>>> f(1,2,param=3)
args:  (1, 2)  kwargs:  {'param': 3}

http://docs.python.org/reference/expressions.html

С помощью num_holes=None по умолчанию хорошо, если вы собираетесь просто __init__,

Если вам нужно несколько независимых "конструкторов", вы можете предоставить их как методы класса. Обычно они называются фабричными методами. В этом случае вы можете иметь значение по умолчанию для num_holes быть 0,

class Cheese(object):
    def __init__(self, num_holes=0):
        "defaults to a solid cheese"
        self.number_of_holes = num_holes

    @classmethod
    def random(cls):
        return cls(randint(0, 100))

    @classmethod
    def slightly_holey(cls):
        return cls(randint(0, 33))

    @classmethod
    def very_holey(cls):
        return cls(randint(66, 100))

Теперь создайте объект так:

gouda = Cheese()
emmentaler = Cheese.random()
leerdammer = Cheese.slightly_holey()

Определенно следует отдать предпочтение уже опубликованным решениям, но поскольку никто еще не упомянул об этом решении, я думаю, что стоит упомянуть о полноте.

@classmethod подход может быть изменен, чтобы обеспечить альтернативный конструктор, который не вызывает конструктор по умолчанию (__init__). Вместо этого экземпляр создается с использованием __new__,

Это может быть использовано, если тип инициализации не может быть выбран на основе типа аргумента конструктора, и конструкторы не разделяют код.

Пример:

class MyClass(set):

    def __init__(self, filename):
        self._value = load_from_file(filename)

    @classmethod
    def from_somewhere(cls, somename):
        obj = cls.__new__(cls)  # Does not call __init__
        obj._value = load_from_somewhere(somename)
        return obj

Все эти ответы превосходны, если вы хотите использовать необязательные параметры, но другая возможность Pythonic - использовать метод класса для генерации псевдо-конструктора в заводском стиле:

def __init__(self, num_holes):

  # do stuff with the number

@classmethod
def fromRandom(cls):

  return cls( # some-random-number )

Почему вы думаете, что ваше решение "неуклюже"? Лично я предпочел бы один конструктор со значениями по умолчанию над несколькими перегруженными конструкторами в ситуациях, подобных вашей (Python в любом случае не поддерживает перегрузку методов):

def __init__(self, num_holes=None):
    if num_holes is None:
        # Construct a gouda
    else:
        # custom cheese
    # common initialization

Для действительно сложных случаев с множеством разных конструкторов может быть удобнее использовать разные фабричные функции:

@classmethod
def create_gouda(cls):
    c = Cheese()
    # ...
    return c

@classmethod
def create_cheddar(cls):
    # ...

В вашем примере с сыром вы можете использовать подкласс сыра Гауда, хотя...

Это хорошие идеи для вашей реализации, но если вы предоставляете пользователю интерфейс для создания сыра. Они не заботятся о том, сколько отверстий имеет сыр или какие внутренние компоненты идут на его изготовление. Пользователь вашего кода просто хочет "гауда" или "пармезан", верно?

Так почему бы не сделать это:

# cheese_user.py
from cheeses import make_gouda, make_parmesean

gouda = make_gouda()
paremesean = make_parmesean()

И тогда вы можете использовать любой из методов, описанных выше, чтобы фактически реализовать функции:

# cheeses.py
class Cheese(object):
    def __init__(self, *args, **kwargs):
        #args -- tuple of anonymous arguments
        #kwargs -- dictionary of named arguments
        self.num_holes = kwargs.get('num_holes',random_holes())

def make_gouda():
    return Cheese()

def make_paremesean():
    return Cheese(num_holes=15)

Это хорошая техника инкапсуляции, и я думаю, что это более Pythonic. Для меня этот способ ведения дел больше соответствует стрельбе по утке. Вы просто запрашиваете объект Гауда, и вам все равно, какой это класс.

Обзор

Что касается конкретного примера с сыром, я согласен со многими другими ответами об использовании значений по умолчанию для сигнализации случайной инициализации или использования статического фабричного метода. Однако могут быть также связанные сценарии, которые вы имели в виду, когда имеет смысл иметь альтернативные, краткие способы вызова конструктора без ущерба для качества имен параметров или информации о типе.

Начиная с Python 3.8 и functools.singledispatchmethod может помочь в этом во многих случаях (и более гибкий может применяться в еще большем количестве сценариев). (В этом связанном сообщении описывается, как можно сделать то же самое в Python 3.4 без библиотеки.) Я не встречал в документации примеров того, что конкретно показывает перегрузку, о которой вы спрашиваете, но похоже, что те же принципы для перегрузки применяется любой метод члена (как показано ниже).

«Единичная отправка» (доступная в стандартной библиотеке) требует наличия по крайней мере одного позиционного параметра и чтобы тип первого аргумента был достаточным для различения возможных перегруженных опций. Для конкретного примера Cheese это не выполняется, так как вам нужны случайные дыры, когда не указаны параметры, но поддерживает тот же синтаксис и может использоваться, если каждая версия метода может различаться на основе количества и типа всех аргументы вместе.

Пример

Вот пример того, как использовать любой из методов (некоторые детали приведены для того, чтобы удовлетворить mypy, что было моей целью, когда я впервые собрал это вместе):

      from functools import singledispatchmethod as overload
# or the following more flexible method after `pip install multimethod`
# from multimethod import multidispatch as overload


class MyClass:

    @overload  # type: ignore[misc]
    def __init__(self, a: int = 0, b: str = 'default'):
        self.a = a
        self.b = b

    @__init__.register
    def _from_str(self, b: str, a: int = 0):
        self.__init__(a, b)  # type: ignore[misc]

    def __repr__(self) -> str:
        return f"({self.a}, {self.b})"


print([
    MyClass(1, "test"),
    MyClass("test", 1),
    MyClass("test"),
    MyClass(1, b="test"),
    MyClass("test", a=1),
    MyClass("test"),
    MyClass(1),
    # MyClass(),  # `multidispatch` version handles these 3, too.
    # MyClass(a=1, b="test"),
    # MyClass(b="test", a=1),
])

Выход:

      [(1, test), (1, test), (0, test), (1, test), (1, test), (0, test), (1, default)]

Заметки:

  • Обычно я не стал бы вызывать псевдоним, но он помог сделать разницу между использованием двух методов, просто в зависимости от того, какой импорт вы используете.
  • В # type: ignore[misc] комментарии не обязательны, но я помещаю их туда, чтобы угодить mypy который не любит ни украшать, ни звонить напрямую.
  • Если вы новичок в синтаксисе декоратора, помните, что @overload до определения просто сахар для __init__ = overload(the original definition of __init__). В таком случае, overload является классом, поэтому в результате получается объект, имеющий __call__ метод, чтобы он выглядел как функция, но также имел .register метод, который вызывается позже, чтобы добавить еще одну перегруженную версию __init__. Это немного запутано, но это приятно, потому что имена методов не определены дважды. Если вас не волнует mypy и вы все равно планируете использовать внешнюю библиотеку, multimethod также есть более простые альтернативные способы указания перегруженных версий.
  • Определение __repr__ просто для того, чтобы напечатанный вывод был осмысленным (в общем, он вам не нужен).
  • Заметь multidispatch может обрабатывать три дополнительных входных комбинации, не имеющих никаких позиционных параметров.

Использование num_holes=None вместо этого по умолчанию. Затем проверьте, num_holes is Noneи, если это так, рандомизируйте. Во всяком случае, это то, что я обычно вижу.

Более радикально разные методы построения могут оправдывать метод класса, который возвращает экземпляр cls,

Лучший ответ - приведенный выше об аргументах по умолчанию, но мне было весело писать это, и это, безусловно, отвечает требованиям "множественных конструкторов". Используйте на свой риск.

Как насчет нового метода.

"Типичные реализации создают новый экземпляр класса, вызывая метод new () суперкласса, используя super (currentclass, cls). New (cls [,...]) с соответствующими аргументами, а затем изменяя вновь созданный экземпляр по мере необходимости до возвращая это."

Таким образом, вы можете сделать так, чтобы новый метод изменил определение вашего класса, подключив соответствующий метод конструктора.

class Cheese(object):
    def __new__(cls, *args, **kwargs):

        obj = super(Cheese, cls).__new__(cls)
        num_holes = kwargs.get('num_holes', random_holes())

        if num_holes == 0:
            cls.__init__ = cls.foomethod
        else:
            cls.__init__ = cls.barmethod

        return obj

    def foomethod(self, *args, **kwargs):
        print "foomethod called as __init__ for Cheese"

    def barmethod(self, *args, **kwargs):
        print "barmethod called as __init__ for Cheese"

if __name__ == "__main__":
    parm = Cheese(num_holes=5)

Я бы использовал наследство. Особенно, если будет больше различий, чем количество отверстий. Особенно, если у Гауды должны быть другие члены, чем у Пармезана.

class Gouda(Cheese):
    def __init__(self):
        super(Gouda).__init__(num_holes=10)


class Parmesan(Cheese):
    def __init__(self):
        super(Parmesan).__init__(num_holes=15) 

Поскольку мой первоначальный ответ подвергся критике на том основании, что мои конструкторы специального назначения не вызывали (уникальный) конструктор по умолчанию, я публикую здесь измененную версию, которая учитывает пожелания, чтобы все конструкторы вызывали версию по умолчанию:

class Cheese:
    def __init__(self, *args, _initialiser="_default_init", **kwargs):
        """A multi-initialiser.
        """
        getattr(self, _initialiser)(*args, **kwargs)

    def _default_init(self, ...):
        """A user-friendly smart or general-purpose initialiser.
        """
        ...

    def _init_parmesan(self, ...):
        """A special initialiser for Parmesan cheese.
        """
        ...

    def _init_gauda(self, ...):
        """A special initialiser for Gauda cheese.
        """
        ...

    @classmethod
    def make_parmesan(cls, *args, **kwargs):
        return cls(*args, **kwargs, _initialiser="_init_parmesan")

    @classmethod
    def make_gauda(cls, *args, **kwargs):
        return cls(*args, **kwargs, _initialiser="_init_gauda")

Вот как я решил это для YearQuarter класс, который я должен был создать. Я создал __init__ с одним параметром под названием value, Код для __init__ просто решает, какой тип value параметр и обработать данные соответственно. Если вам нужны несколько входных параметров, вы просто упаковываете их в один кортеж и проверяете value быть кортежем

Вы используете это так:

>>> temp = YearQuarter(datetime.date(2017, 1, 18))
>>> print temp
2017-Q1
>>> temp = YearQuarter((2017, 1))
>>> print temp
2017-Q1

И вот как __init__ а остальная часть класса выглядит так:

import datetime


class YearQuarter:

    def __init__(self, value):
        if type(value) is datetime.date:
            self._year = value.year
            self._quarter = (value.month + 2) / 3
        elif type(value) is tuple:               
            self._year = int(value[0])
            self._quarter = int(value[1])           

    def __str__(self):
        return '{0}-Q{1}'.format(self._year, self._quarter)

Вы можете расширить __init__ с несколькими сообщениями об ошибках, конечно. Я опустил их для этого примера.

Прямого ответа с примером пока не вижу. Идея проста:

  • использовать в качестве «базового» конструктора, поскольку python позволяет только один __init__метод
  • использовать @classmethodдля создания любых других конструкторов и вызова базового конструктора

Вот новая попытка.

       class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    @classmethod
    def fromBirthYear(cls, name, birthYear):
        return cls(name, date.today().year - birthYear)

Применение:

      p = Person('tim', age=18)
p = Person.fromBirthYear('tim', birthYear=2004)
class Cheese:
    def __init__(self, *args, **kwargs):
        """A user-friendly initialiser for the general-purpose constructor.
        """
        ...

    def _init_parmesan(self, *args, **kwargs):
        """A special initialiser for Parmesan cheese.
        """
        ...

    def _init_gauda(self, *args, **kwargs):
        """A special initialiser for Gauda cheese.
        """
        ...

    @classmethod
    def make_parmesan(cls, *args, **kwargs):
        new = cls.__new__(cls)
        new._init_parmesan(*args, **kwargs)
        return new

    @classmethod
    def make_gauda(cls, *args, **kwargs):
        new = cls.__new__(cls)
        new._init_gauda(*args, **kwargs)
        return new

Здесь (опираясь на этот более ранний ответ , чистая версия Python classmethodв документах и, как предлагается в этом комментарии ), это декоратор, который можно использовать для создания нескольких конструкторов.

      from types import MethodType
from functools import wraps

class constructor:
    def __init__(self, func):

        @wraps(func)                      
        def wrapped(cls, *args, **kwargs):
            obj = cls.__new__(cls)        # Create new instance but don't init
            super(cls, obj).__init__()    # Init any classes it inherits from
            func(obj, *args, **kwargs)    # Run the constructor with obj as self
            return obj                
        
        self.wrapped = wrapped

    def __get__(self, _, cls):
        return MethodType(self.wrapped, cls)   # Bind this constructor to the class 
        
    
class Test:
    def __init__(self, data_sequence):
        """ Default constructor, initiates with data sequence """
        self.data = [item ** 2 for item in data_sequence]
        
    @constructor
    def zeros(self, size):
        """ Initiates with zeros """
        self.data = [0 for _ in range(size)]
           
a = Test([1,2,3])
b = Test.zeros(100)

В некоторых случаях это кажется самым чистым способом (см., например, несколько конструкторов фреймов данных в Pandas ), где предоставление нескольких необязательных аргументов одному конструктору было бы неудобно: например, в случаях, когда для этого потребуется слишком много параметров, он будет нечитаемым, будет медленнее или использовать больше памяти, чем это необходимо. Однако, как утверждалось в предыдущих комментариях, в большинстве случаев, вероятно, более Pythonic маршрутизировать через один конструктор с необязательными параметрами, добавляя методы класса, где это необходимо.

Это довольно чистый способ, я думаю и хитрый

class A(object):
    def __init__(self,e,f,g):

        self.__dict__.update({k: v for k,v in locals().items() if k!='self'})
    def bc(self):
        print(self.f)


k=A(e=5,f=6,g=12)
k.bc() # >>>6

Я не думаю, что какой-либо из ответов здесь дает правильный ответ, хотя некоторые близки к этому.

Многие ответы предполагают что-то вроде следующего:

  • Предоставьте «наиболее общую» функцию, которая принимает все возможные аргументы.
  • должен (в общем) иметь сложную логику для проверки всех аргументов на согласованность, а затем устанавливать данные-члены в зависимости от этих аргументов
  • Другие «функции-конструкторы» должны иметь более конкретные комбинации аргументов, и все они должны вызывать

Я считаю, что это неправильный дизайн. К сожалению, пример, приведенный OP, слишком прост, чтобы полностью показать, почему это плохой дизайн, поскольку в этом случае тип «сыр» во всех случаях принимает только одно целочисленное значение.

Чтобы понять, почему это плохо, нам нужно увидеть более сложный пример.

Это из того, над чем я работаю:

Используя приведенную выше парадиму, мы в конечном итоге пишем:

      class ExperimentRecord():

    def __init__(self, experiment_index=None, dictionary=None):

        if experiment_index is None and dictionary is None:
            raise ExperimentalDatabaseException(f'constructing instance of ExperimentalRecord requires either experiment_index or dictionary to be specified')
        elif experiment_index is not None and dictionary is not None:
            raise ExperimentalDatabaseException(f'constructing instance of ExperimentalRecoed requires either experiment_index or dictionary to be specified, but not both')
        elif experiment_index is None and dictionary is not None:
            self.experiment_index = dictionary['index']
            self.record_type = dictionary['record_type']
            self.data = dictionary['data']
            self.measurement_amplitude = dictionary['amplitude']
            self.measurement_mean = dictionary['mean']
            self.measurement_stddev = dictionary['stddev']
            self.measurement_log_likelihood = dictionary['log_likelihood']
        elif experiment_index is not None and dictionary is None:
            self.experiment_index = experiment_index
            self.record_type = None
            self.data = None
            self.measurement_amplitude = None
            self.measurement_mean = None
            self.measurement_stddev = None
            self.measurement_log_likelihood = None

Полученный код, если говорить прямо (и я говорю это как человек, написавший этот код), шокирующе плох. Вот причины, почему:

  • должен использовать сложную комбинаторную логику для проверки аргументов
  • если аргументы образуют допустимую комбинацию, то он выполняет обширную инициализацию в той же функции
  • это нарушает принцип единой ответственности и приводит к созданию сложного кода, который трудно поддерживать или даже понимать.
  • его можно улучшить, добавив две функцииино это приводит к добавлению дополнительных функций без какой-либо другой цели, кроме как попытаться сохранить управляемость функции.
  • это совершенно не то, как работают несколько конструкторов в таких языках, как Java, C++ или даже Rust. Обычно мы ожидаем, что перегрузка функций позволит разделить логику различных способов инициализации чего-либо на совершенно независимые функции . Здесь мы смешали все в одну функцию, что является полной противоположностью тому, чего мы хотим достичь.

Далее, в этом примере инициализация зависит только от двух переменных. Но я мог бы легко добавить третье:

  • Например, мы можем захотеть инициализировать экспериментальную запись из строки или даже имени файла/пути или дескриптора файла.
  • Мы можем представить, что сложность возрастает по мере появления большего количества возможных методов инициализации.
  • В более сложных случаях каждый аргумент может не быть независимым. Мы могли бы представить возможные случаи инициализации, когда действительные инициализации формируются из подмножества возможных аргументов, где подмножества каким-то сложным образом перекрываются.

Например:

Некоторый объект может принимать аргументы . Возможно, будет допустимо инициализировать, используя следующие комбинации:

  • A
  • B, C, D
  • D, E
  • A, E

Это абстрактный пример, потому что трудно придумать простой пример. Однако, если вы какое-то время занимаетесь разработкой программного обеспечения, вы знаете, что такие примеры могут иногда возникать и действительно возникают, независимо от того, указывает ли их существование на какие-то недостатки в общей конструкции.


Учитывая вышесказанное, это то, над чем я сейчас работаю. Вероятно, он не идеален, я только начал работать с Python в контексте, который со вчерашнего дня требовал от меня написания «нескольких конструкторов» .

Устраняем проблемы, выполняя следующие действия:

  • создайте «нулевой» конструктор. Он должен выполнять работу конструктора, который не принимает аргументов.
  • Добавьте функции-конструкторы, которые каким-либо образом изменяют объект после вызова нулевого конструктора ()
  • Или, если вариант использования допускает наследование, используйте шаблон наследования, как предлагали другие. (Это может быть или не быть «лучше» в зависимости от контекста)

Что-то вроде этого, может быть

      class ExperimentRecord():

    def __init__():
        self.experiment_index = None
        self.record_type = None
        self.data = None
        self.measurement_amplitude = None
        self.measurement_mean = None
        self.measurement_stddev = None
        self.measurement_log_likelihood = None

    @classmethod
    def from_experiment_index(cls, experiment_index):
        tmp = cls() # calls `__new__`, `__init__`, unless I misunderstand
        tmp.experiment_index = experiment_index
        return tmp

    @classmethod
    def from_dictionary(cls, dictionary):
        tmp = cls()
        tmp .experiment_index = dictionary['index']
        tmp .record_type = dictionary['record_type']
        tmp .data = dictionary['data']
        tmp .measurement_amplitude = dictionary['amplitude']
        tmp .measurement_mean = dictionary['mean']
        tmp .measurement_stddev = dictionary['stddev']
        tmp .measurement_log_likelihood = dictionary['log_likelihood']
        return tmp

С помощью данной конструкции мы решаем следующие задачи:

  • принцип единой ответственности: каждая функция-конструктор полностью независима и самостоятельно инициализирует объект.
  • каждая функция-конструктор принимает аргументы, необходимые для инициализации, и не более того. каждый возможный метод инициализации требует своего собственного набора аргументов, и эти наборы аргументов независимы и не объединены в один вызов функции.

Примечание. Поскольку я буквально только что об этом подумал, возможно, я что-то упустил из виду. Если это так, оставьте комментарий с объяснением недостатков, и я постараюсь придумать решение, а затем обновлю ответ. Кажется, это работает для моего конкретного случая использования, но всегда есть вероятность, что я что-то упустил из виду, особенно потому, что до сегодняшнего дня я не знал, что мне нужно исследовать написание нескольких конструкторов Python.

Другие вопросы по тегам