Шаблон проектирования Python: использование атрибутов класса для хранения данных по сравнению с переменными локальной функции - PullRequest
9 голосов
/ 16 апреля 2019

Часто я сталкиваюсь с одним и тем же вопросом. Распространенный шаблон - я создаю класс, который выполняет некоторые операции. Например. Загружает данные, преобразует / очищает данные, сохраняет данные. Тогда возникает вопрос, как передать / сохранить промежуточные данные. Посмотрите на следующие 2 варианта:

import read_csv_as_string, store_data_to_database

class DataManipulator:
    ''' Intermediate data states are saved in self.results'''

    def __init__(self):
        self.results = None

    def load_data(self):
        '''do stuff to load data, set self.results'''
        self.results = read_csv_as_string('some_file.csv')

    def transform(self):
        ''' transforms data, eg get first 10 chars'''
        transformed = self.results[:10]
        self.results = transformed

    def save_data(self):
        ''' stores string to database'''
        store_data_to_database(self.results)

    def run(self):
        self.load_data()
        self.transform()
        self.save_data()

DataManipulator().run()

class DataManipulator2:
    ''' Intermediate data states are not saved but passed along'''


    def load_data(self):
        ''' do stuff to load data, return results'''
        return read_csv_as_string('some_file.csv')

    def transform(self, results):
        ''' transforms data, eg get first 10 chars'''
        return results[:10]

    def save_data(self, data):
        ''' stores string to database'''
        store_data_to_database(data)

    def run(self):
        results = self.load_data()
        trasformed_results = self.transform(results)
        self.save_data(trasformed_results)

DataManipulator2().run()

Теперь для написания тестов я считаю DataManipulator2 лучше, поскольку функции легче тестировать в изоляции. В то же время мне также нравится функция чистого запуска DataManipulator. Какой самый питонический способ?

Ответы [ 3 ]

3 голосов
/ 03 мая 2019

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

Как вы написали, DataManipulator2 кажется на первый взгляд легче тестировать.(Но, как сказал @AliFaizan, не так просто модульный тест функция, для которой требуется соединение с базой данных.) И, кажется, проще тестировать, потому что это без состояний .Класс без состояния автоматически не проще для тестирования, но его легче понять: для одного входа вы всегда получаете один и тот же вывод.

Но это не единственная точка: с DataManipulator2, порядокдействия в run не могут быть неправильными, потому что каждая функция передает некоторые данные следующей, а следующая не может продолжаться без этих данных.Это было бы более очевидно при использовании статически (и строго) типизированного языка, потому что вы даже не можете скомпилировать ошибочную функцию run.

Напротив, DataManipulator не легко проверить, проверить с состоянием и неНе следите за порядком действий.Вот почему метод DataManipulator.run такой чистый.Это событие слишком чистое , потому что его реализация скрывает нечто очень важное: упорядочены вызовы функций.

Следовательно, мой ответ: предпочитает реализацию DataManipulator2 реализации DataManipulator.

Но совершенен ли DataManipulator2?И да и нет.Для быстрой и грязной реализации это путь.Но давайте попробуем пойти дальше.

Вам нужна функция run, чтобы быть публичной, но у load_data, save_data и transform нет причин быть публичной (под "публичной" я имею в виду:не помечены как детали реализации с подчеркиванием).Если вы пометите их подчеркиванием, они больше не являются частью контракта, и вам неудобно их тестировать.Зачем?Потому что реализация может измениться, не нарушая контракт класса, хотя могут быть и сбои тестов.Это жестокая дилемма: либо ваш класс DataManipulator2 имеет правильный API, либо он не полностью тестируемый.

Тем не менее, эти функции должны быть тестируемыми, но как часть API другого класса.Представьте себе трехуровневую архитектуру:

  • load_data и save_data находятся на уровне данных
  • transform находятся на бизнес-уровне.
  • run вызов находится на уровне представления

Давайте попробуем реализовать это:

class DataManipulator3:
    def __init__(self, data_store, transformer):
        self._data_store = data_store
        self._transformer = transformer

    def run(self):
        results = self._data_store.load()
        trasformed_results = self._transformer.transform(results)
        self._data_store.save(transformed_results)

class DataStore:
    def load(self):
        ''' do stuff to load data, return results'''
        return read_csv_as_string('some_file.csv')

    def save(self, data):
        ''' stores string to database'''
        store_data_to_database(data)

class Transformer:
    def transform(self, results):
        ''' transforms data, eg get first 10 chars'''
        return results[:10]

DataManipulator3(DataStore(), Transformer()).run()

Это неплохо, а Transformer легко проверить.Но:

  • DataStore не удобен: файл для чтения похоронен в коде и в базе данных.
  • DataManipulator должен иметь возможность запускать Transformer для нескольких выборок данных.

Следовательно, существует еще одна версия, которая решает эти проблемы:

class DataManipulator4:
    def __init__(self, transformer):
        self._transformer = transformer

    def run(self, data_sample):
        data = data_sample.load()
        results = self._transformer.transform(data)
        self.data_sample.save(results)

class DataSample:
    def __init__(self, filename, connection)
        self._filename = filename
        self._connection = connection

    def load(self):
        ''' do stuff to load data, return results'''
        return read_csv_as_string(self._filename)

    def save(self, data):
        ''' stores string to database'''
        store_data_to_database(self._connection, data)

with get_db_connection() as conn:
    DataManipulator4(Transformer()).run(DataSample('some_file.csv', conn))

Есть еще один момент: имя файла.Попытайтесь предпочесть файловый объект именам файлов в качестве аргументов, потому что вы можете протестировать свой код с помощью io модуля :

class DataSample2:
    def __init__(self, file, connection)
        self._file = file
        self._connection = connection

    ...

dm = DataManipulator4(Transformer())
with get_db_connection() as conn, open('some_file.csv') as f:
    dm.run(DataSample2(f, conn))

с макетами объектов ,теперь очень легко протестировать поведение классов.

Давайте подведем итог преимуществам этого кода:

  • порядок действий гарантирован (как в DataManipulator2)
  • метод run настолько чист, как и должно быть (как в DataManipulator2)
  • код является модульным: вы можете создать новый Transformer или новый DataSample (загрузить из БД и сохранить, например, в CSV-файл)
  • код тестируемый: каждый метод общедоступен (в смысле Python), но API остаются простыми.

КонечноЭто действительно (старый стиль) Java-подобный.В python вы можете просто передать функцию transform вместо экземпляра класса Transformer.Но как только ваш transform начинает становиться сложным, класс - хорошее решение.

3 голосов
/ 29 апреля 2019

Какой самый питонический способ?

Python поддерживает несколько парадигм. Ваша вторая форма ближе к функциональной, первая более обязательна. Это строго вопрос предпочтения, без контекста .

Однако у меня есть 3-е предложение, потому что мне нравится, когда объекты не сохраняют состояние, когда этого можно избежать. Это легко проверить и избежать всевозможных проблем в сложном неисправном методе run() (например, преобразование перед загрузкой, преобразование вызывается дважды, сохранение без преобразования и т. Д.).


class DataTransformer:
    @classmethod
    def from_csv(cls, some_file):
        '''Because I don't like __init__ to do logic, it's harmful for testability, 
        but at the same time this is needed data for proper initialization
        '''
        return cls(read_csv_as_string(some_file))

    def __init__(self, raw_data):
        ''' Feel free to init with bogus test data '''
        self.raw_data = raw_data

    def transform(self):
        ''' Returning the data instead of a ContentSaver is a less coupled design (suppose you add more exporters)'''
        return self.raw_data[:10]

class ContentSaver:
    '''Having a different class makes sense now the data is transformed:
    it's a different type of data, from a logical standpoint.'''
    def __init__(self, some_content):
        self.content = some_content

    def save_data(self):
        store_data_to_database(self.content)

def run():
    '''Note this code part isn't easily testable, so it's better if possible mistakes are made fewer.'''
    transformer = DataTransformer.from_csv('some_file')
    writer = ContentSaver(transformer.transform())
    # Possible further uses of transformer and writer without care of order
    writer.save_data()

В любой момент времени жизни моих объектов они содержат инициализированные данные согласованного типа. Это делает их тестируемыми, менее подверженными ошибкам и более вероятно, будет полезно в различных реализациях (не только run()).

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

0 голосов
/ 30 апреля 2019

Я не буду вдаваться в функциональность против императивного стиля. Если язык предоставляет вам функцию, которая облегчает вашу жизнь, используйте ее независимо от философии.
Вы сказали, что DataManipulator2 проще для тестирования. Я не согласен с этим. Например, в функции save_data вы передадите data в качестве ввода в DataManipulator2. В DataManipulator вы должны будете использовать его как fixture. Взгляните на две самые известные библиотеки тестирования Python pytest и unittest, чтобы изучить различные стили написания тестов.
Теперь я вижу, что нужно учитывать две вещи. Первая простота в использовании. Вы упомянули, что вы найдете DataManipulator более чистым. Это показывает, что этот путь более естественен для вас и, возможно, для вашей команды. Независимо от того, сколько раз я говорю, что DataManipulator2 проще и чище, именно вы будете изменять, поддерживать и объяснять код другим людям. Так что придерживайтесь подхода, наиболее ясного для вас. Вторая важная вещь, которую вы должны учитывать, это то, насколько близко ваш код связан с вашими данными (я не верю, что какой-либо подход неправильный). При первом подходе всякий раз, когда вы будете выполнять какое-либо действие, оно * изменит ваше состояние (не ВСЕГДА. Ваши функции могут выполнять действие на self.result и выдавать результат без изменения состояния). Вы можете посмотреть на это так, как будто вы редактируете файл с включенным автосохранением. Разница лишь в том, что вы не можете отменить (по крайней мере, с помощью ctr / cmd + z). Во втором варианте вы или пользователь вашего класса решите, хотят ли они сохранить. Может быть немного больше работы, но свобода как для создателя, так и для пользователя класса.
Вердикт : Определите цель вашего класса, его обязанности и общую структуру вашего кода. Если это ориентированный на данные класс, например data python 3.7 классы данных с Frozen=False, идут с первым подходом. Если это класс стиля service (для других частей кода его следует рассматривать как REST api), используйте второй подход.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...