Совместное использование атрибутов между классами: является ли множественное наследование правильным и «pythonic» здесь?

#python #python-3.x #python-2.7 #multiple-inheritance

#python #python-3.x #python-2.7 #множественное наследование

Вопрос:

У меня есть вариант использования, когда множественное наследование кажется правильным. Но это подразумевает совместное использование атрибутов между «родственными» классами, атрибутов, которые инициализированы в других классах (поэтому каким-то образом неизвестны для них). Я спрашиваю, является ли приведенная ниже модель «правильной» и «pythonic», или мне лучше использовать модель с активированными классами.

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

Сначала введите код, чтобы заставить работать приведенные ниже примеры:

 import sys
PY3 = not sys.version_info < (3,)
from string import Template
import csv, io, smtplib, requests, os

def read_test_movies(year_from, year_to, genre= None):
  TEST_MOVIES= [
    {'year': 1971, 'release': '01/01/1971', 'genre': 'thriller', 'title': 'Play Misty for Me'},
    {'year': 1973, 'release': '02/02/1973', 'genre': 'romantic', 'title': 'Breezy'},
    {'year': 1976, 'release': '03/03/1976', 'genre': 'western', 'title': 'The Outlaw'},
    {'year': 1986, 'release': '04/04/1986', 'genre': 'war', 'title': 'Heartbreak'},
    {'year': 1988, 'release': '05/05/1988', 'genre': 'music', 'title': 'Bird'},
    {'year': 1992, 'release': '06/06/1992', 'genre': 'western', 'title': 'Unforgiven'},
    {'year': 1995, 'release': '07/07/1995', 'genre': 'romantic', 'title': 'The Bridges of Madison County'},
    {'year': 2000, 'release': '08/08/2000', 'genre': 'space', 'title': 'Space Cowboys'},
    {'year': 2003, 'release': '09/09/2003', 'genre': 'trhiller', 'title': 'Mystic River'},
    {'year': 2004, 'release': '10/10/2004', 'genre': 'sports', 'title': 'Million Dollar Baby'},
    {'year': 2006, 'release': '11/11/2006', 'genre': 'war', 'title': 'Flags of Our Fathers'},
    {'year': 2006, 'release': '12/12/2006', 'genre': 'war', 'title': 'Letters from Iwo Jima'},
    {'year': 2008, 'release': '13/11/2008', 'genre': 'drama', 'title': 'Changeling'},
    {'year': 2008, 'release': '14/10/2008', 'genre': 'drama', 'title': 'Gran Torino'},
    {'year': 2009, 'release': '15/09/2009', 'genre': 'sports', 'title': 'Invictus'},
    {'year': 2010, 'release': '16/08/2010', 'genre': 'drama', 'title': 'Hereafter'},
    {'year': 2011, 'release': '17/07/2011', 'genre': 'drama', 'title': 'J. Edgar'},
    {'year': 2014, 'release': '18/06/2014', 'genre': 'war', 'title': 'American Sniper'},
    {'year': 2016, 'release': '19/05/2016', 'genre': 'drama', 'title': 'Sully'}
  ]
  out= []
  for m in TEST_MOVIES:
    if year_from <= m['year'] and m['year'] <= year_to:
      if genre is None or (genre is not None and genre == m['genre']):
        out.append(m)
  return out
  

Поскольку эти три части (data — format — send) настолько различимы, мы бы начали с этих интерфейсоподобных классов (я думаю, что abc тоже можно использовать):

 class ITheData(object):

  def __init__(self, year_from, year_to, genre= None):
    self.year_from= year_from
    self.year_to  = year_to
    self.genre    = genre

  def readMovies(self):
    raise NotImplementedError('%s.readMovies() must be implemented' % self.__class__.__name__)

class ITheFormat(object):
  def filename(self):
    raise NotImplementedError('%s.filename() must be implemented' % self.__class__.__name__)

  def make(self):
    raise NotImplementedError('%s.make() must be implemented' % self.__class__.__name__)

class ITheSend(object):
  def send(self):
    raise NotImplementedError('%s.send() must be implemented' % self.__class__.__name__)
  

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

 class ITheDeliverer(ITheData, ITheFormat, ITheSend):
  def deliver(self):
    raise NotImplementedError('%s.deliver() must be implemented' % self.__class__.__name__)
  

Итак, у нас могло бы быть два разных источника данных. Помимо исходного кода, они могут отличаться в действиях последующей обработки. Хотя для простоты я просто делаю self.readMovies() повсеместно, это может быть какой-то другой пользовательский метод в подклассе.

 class TheIMDBData(ITheData):
  def readMovies(self):
    # movies = some_read_from_IMDB(self.genre, self.year_from, self.year_to)
    movies= read_test_movies(self.year_from, self.year_to, self.genre)
    return movies

class TheTMDbData(ITheData):
  def readMovies(self):
    # movies = some_read_from_TMDb(self.genre, self.year_from, self.year_to)
    movies= read_test_movies(self.year_from, self.year_to, self.genre)
    return movies
  

We could use also two different formats:

 class TheTXTFormat(ITheFormat):
  def filename(self):
    # Here `genre`, `year_from` and `year_to` are unknown
    params= {'genre': self.genre, 'year_from': self.year_from, 'year_to': self.year_to}
    return Template('movies_of_${genre}_from_${year_from}_to_${year_to}.txt').substitute(**params)

  def make(self):
    # Here `readMovies()` is unknown
    strio = PY3 and io.StringIO() or io.BytesIO()
    for movie in self.readMovies():
      line= Template('$title, released on $release').substitute(**movie)
      line = 'n'
      strio.write(line)
    strio.seek(0)
    return strio.read()

class TheCSVFormat(ITheFormat):
  def filename(self):
    # Here `genre`, `year_from` and `year_to` are unknown
    params= {'genre': self.genre, 'year_from': self.year_from, 'year_to': self.year_to}
    return Template('movies_of_${genre}_from_${year_from}_to_${year_to}.csv').substitute(**params)

  def make(self):
    # Here `readMovies()` is unknown
    strio = PY3 and io.StringIO() or io.BytesIO()
    writer = csv.writer(strio, delimiter=';', quotechar='"', quoting=csv.QUOTE_MINIMAL)
    header = ('Title', 'Release')
    writer.writerow(header)
    for movie in self.readMovies():
      writer.writerow((movie['title'], movie['release']))
    strio.seek(0)
    return strio.read()
  

And two different sending channels:

 class TheMailSend(ITheSend):
  host      = 'localhost'
  sender    = 'movie@spammer.com'
  receivers = ['movie@spammed.com']

  def send(self):
    # Here `make()` is unknown
    print('TheMailSend.send() Sending to %s' % str(self.receivers))
    try:
      message = self.make()  # Format agnostic
      smtpObj = smtplib.SMTP(self.host)
      smtpObj.sendmail(self.sender, self.receivers, message)
      return True, 'ok'
    except Exception as ss:
      return False, str(ss)

class TheWSSend(ITheSend):
  url = 'spammed.com/movies/send'

  def send(self):
    # Here `make()` is unknown
    print('TheWSSend.send() Sending to %s' % str(self.url))
    try:
      content = self.make()  # Format agnostic
      s= requests.Session()
      response= s.post(url= self.url, data= {'content': content})
      s.close()
      if response.status_code == 200:
        return True, 'ok'
      else:
        return False, response.status_code
    except Exception as ss:
      return False, str(ss)
  

So, we could end with some deliverers like these:

 class TheIMDBToTXTFile(ITheDeliverer, TheIMDBData, TheTXTFormat):
  def __init__(self, year_from, year_to, genre= None):
    TheIMDBData.__init__(self, year_from, year_to, genre)

  def deliver(self):
    filepath= os.path.join('/tmp', self.filename())
    f= open(filepath, 'w')
    f.write(self.make())
    f.close()
    print('TheIMDBToTXTFile.deliver() => Successfully delivered to %s' % str(filepath))

class TheIMDBToWS(ITheDeliverer, TheIMDBData, TheTXTFormat, TheWSSend):
  def __init__(self, year_from, year_to, genre=None):
    TheIMDBData.__init__(self, year_from, year_to, genre)

  def deliver(self):
    ok, msg = self.send()
    if ok:
      print('TheIMDBToWS.deliver() => Successfully delivered!')
    else:
      print('TheIMDBToWS.deliver() => Error delivering: %s' % str(msg))

class TheTMDbToMail(ITheDeliverer, TheTMDbData, TheCSVFormat, TheMailSend):
  def __init__(self, year_from, year_to, genre=None):
    TheTMDbData.__init__(self, year_from, year_to, genre)

  def deliver(self):
    ok, msg= self.send()
    if ok:
      print('TheTMDbToMail.deliver() => Successfully delivered!')
    else:
      print('TheTMDbToMail.deliver() => Error delivering: %s' % str(msg))
  

И они работают нормально — с очевидными ошибками подключения-:

 >>> imdbToTxt = TheIMDBToTXTFile(year_from= 2000, year_to= 2010)
>>> imdbToTxt.deliver()
TheIMDBToTXTFile.deliver() => Successfully delivered to /tmp/movies_of_None_from_200_to_2010.txt
>>> 
>>> imdbToWs = TheIMDBToWS(year_from= 2000, year_to= 2010)
>>> imdbToWs.deliver()
TheWSSend.send() Sending to http://spammed.com/movies/send?
TheIMDBToWS.deliver() => Error delivering: 405
>>> 
>>> tmdbToMail = TheTMDbToMail(year_from= 1980, year_to= 2019, genre= 'war')
>>> tmdbToMail.deliver()
TheMailSend.send() Sending to ['movie@spammed.com']
TheTMDbToMail.deliver() => Error delivering: [Errno 111] Connection refused

  

Но, как указано, некоторые атрибуты неизвестны для некоторых классов, и линтер — очевидно — жалуется на это:

 Instance of 'TheTXTFormat' has no 'genre' member
Instance of 'TheTXTFormat' has no 'year_from' member
Instance of 'TheTXTFormat' has no 'year_to' member
Instance of 'TheTXTFormat' has no 'readMovies' member

Instance of 'TheCSVFormat' has no 'genre' member
Instance of 'TheCSVFormat' has no 'year_from' member
Instance of 'TheCSVFormat' has no 'year_to' member
Instance of 'TheCSVFormat' has no 'readMovies' member

Instance of 'TheMailSend' has no 'make' member
Instance of 'TheWSSend' has no 'make' member
  

Итак, остается вопрос: является ли здесь множественное наследование хорошей моделью?

Альтернативами могут быть: модель производных классов или просто независимые классы и передача параметров типа data или formatter . Но ни один из них не кажется таким простым, как множественное наследование (хотя они бы исправили линтерные — и, вероятно, концептуальные — проблемы).

Ответ №1:

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

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

 from abc import ABC, abstractmethod


class ITheSend(ABC):

    def run(self) -> None:
        """
        The template method defines the skeleton of an algorithm.
        """

        self.pre_send_hook()
        self.send()
        self.post_send_hook()

    # These operations have to be implemented in subclasses.

    @abstractmethod
    def send(self) -> None:
        pass

    # These are "hooks." Subclasses may override them, but it's not mandatory
    # since the hooks already have default (but empty) implementation. Hooks
    # provide additional extension points in some crucial places of the
    # algorithm.

    def pre_send_hook(self) -> None:
        pass

    def post_send_hook(self) -> None:
        pass


class TheMailSend(ITheSend):
  host      = 'localhost'
  sender    = 'movie@spammer.com'
  receivers = ['movie@spammed.com']

  def send(self, message):
    print('TheMailSend.send() Sending to %s' % str(self.receivers))
    try:
      smtpObj = smtplib.SMTP(self.host)
      smtpObj.sendmail(self.sender, self.receivers, message)
      return True, 'ok'
    except Exception as ss:
      return False, str(ss)

class TheWSSend(ITheSend):
  url = 'spammed.com/movies/send'

  def send(self, content):
    print('TheWSSend.send() Sending to %s' % str(self.url))
    try:
      s= requests.Session()
      response= s.post(url= self.url, data= {'content': content})
      s.close()
      if response.status_code == 200:
        return True, 'ok'
      else:
        return False, response.status_code
    except Exception as ss:
      return False, str(ss)
  

Однако для полной цепочки я бы исследовал композицию вместо наследования.

 class Chain:
    def __init__(self, data, format, send):
        self._data = data
        self._format = format
        self._send = send

    def deliver(self):
        data = self._data.execute()
        format = self._format.execute(data)
        send = self._send.execute(format)


the_IMDB_to_TXT_file = Chain(send=ITheDeliverer, data=TheIMDBData, format=TheTXTFormat)