Практический пример использования особенности, известной как "duck typing", для не программирующих на Питоне. Впрочем, программирующим тоже может быть интересно :-)

"Если это ходит, как утка, и крякает, как утка, то значит это утка" — это принцип, по которому в Питоне и некоторых других динамических языках считается, что то, что объект умеет делать, и определят его сущность. Подробнее, как всегда — в Wikipedia.

Задачка

Пару дней назад мне по почте подкинули интересную задачку о реализации обработки заковыристой HTML-формочки на Django. Формочка представляет собой опросный лист, где к каждому вопросу можно выбрать ответ из предложенных вариантов.

Смысл формы вполне очевиден, но заковыристость задачи состоит в том, что автоматизация обработки форм в Django не расчитана на формы такого рода. Формы там бывают двух видов:

Сам автоматизм обработки форм включает в себя валидацию введенных данных, перепоказ их с ошибками и сохранение данных в базу. В общем — ничего не надо делать, и наступает счастье неземное.

Форма опроса похожа как раз на второй вариант: там в одной большой форме находятся несколько подформ, представляющих отдельные вопросы. И вот тут-то человека, надеющегося на помощь фреймворка, подстерегает неприятная неожиданность. Дело в том, что именно для вложенных подформ Django не допускает никакой самодеятельности. Он умеет создавать такие формы только как формы редактирования моделей в базе. Что на практике выливается в то, что вместо радиокнопок с вариантами ответов пользователю покажут текстовые поля редактирования формулировок вопросов :-).

Решение вручную

В принципе, раз автоматические средства для задачи не подходят, всегда можно просто взять данные из POST'а и обработать их вручную. Но обработка как раз таких вот формочек — потрясающе гадкое занятие (по крайней мере для меня). Потому что чтобы отличать одинаково называющиеся элементы форм, приходится приписывать к ним цифры по порядку, потом в них копаться, перебирая и выкусывая цифры из строк... В общем, код получается очень волосатый и нечитаемый. И как раз тут-то было бы особенно полезно воспользоваться тем, что в Django такой механизм в целом есть, хоть и работает не совсем так, как хочется.

Duck typing

Значит — надо влезть в механизм и подхачить его ровно настолько, насколько нужно.

Кстати, вот это "влезть" — то, из-за чего я вряд ли когда-то буду строить свою работу на закрытом коде, где влезть никуда нельзя.

Порывшись в исходниках, я обнаружил, что для того, чтобы построить список подформ, Django использует специальный объект-менеджер, который называется RelatedObject, который:

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

Если вы приблизительно мой ровесник и изучали программирование в вузе, и объектную ориентацию вам, значит, скорее всего объясняли на примере C++, то такое описание должно вызывать у вас желание применить наследование: объект для сходной цели но со слегка измененным поведением. Но как известно, наследование класса в C++ наследует в том числе (а может и в первую очередь) реализацию класса. То есть, мой наследник от RelatedObject все равно будет требовать для своего создания модели данных, и будет создавать из них те ненужные мне поля, которые уже создает. Отменить этого нельзя просто потому, что RelatedObject не проектировался так, чтобы от него было удобно наследовать, и у него нет специально предназначенных для моей задачи виртуальных функций, которые можно перекрыть. Это просто внутренний вспомогательный класс.

Что мне тут, по идее, нужно — это наследование только интерфейса класса, чтобы с точки зрения его пользователя он имел все те же методы, что и оригинальный RelatedObject, но реализовать их мог бы полностью без всякого базового поведения, с нуля. В Delphi и Java для таких вещей есть специальная конструкция — interface — которая как раз и описывает такой набор методов для пользователя класса, и он мог бы выглядеть например так:

IRelatedObject = interface
  Function GetFields: TList;
  Function GetObjects: TList;
end;

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

Но опять таки, из-за того, что RelatedObject никогда не проектировался с расчетом на участие в пользовательском коде, про такой интерфейс, даже про саму необходимость его создания просто никто не думал, и его, соответственно, нет в природе.

Другими словами, наследование реализации в стиле C++ или наследование интерфейса в стиле Delphi и Java работает только тогда, когда разработчик библиотеки продумал эту возможность заранее. Но вот когда этого нет, на сцене и появляется Питон со своей крякающей как-бы-уткой.

В Питоне нет понятия типа объекта, как такового. Для того, чтобы создать объект, который будет работать как RelatedObject, не нужно объявлять, что это — RelatedObject, не нужна реализация оригинального объекта, и даже не нужны все его методы. Достаточно того, чтобы у него были методы, которые у него хочет вызывать конкретный его пользователь. И все. Тогда с точки зрения вызывающего кода это точно такой же объект, как и любой другой, реализующий эти методы. И этому набору даже не надо никак отдельно называться (типа IFormWrapperRelatedObject).

В итоге, я создал сначала совершенно пустой свой RelatedObject, который передал в построитель формы. "У этого RelatedObject нет метода get_manipulator_fields" — откликнулся построитель. Пошел посмотреть, что возвращает такой метод в оригинальном объекте, и создал свой, реализовав его как мне надо. И так шаг за шагом реализовал все нужные. Все — форма строится, сабмитится, валидируется... В общем, крякает, как нормальная утка!

Надежность

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

Комментарии: 13 (особо ценных: 1)

  1. Sergey Yanovitsky

    Коротко о надежности: разработчик, использующий Duck Typing обычно еще и пользуется благами ExtremeProgramming, а именно пишет Unit Test-ы на каждый модуль, которые накапливаются и запускаются все вместе при каждом изменении кода. По этому с нахождением косяков проблем не возникает :)

  2. Иван Сагалаев

    Угу. Именно о тестах, в числе прочего, я и собираюсь в будущем написать.

    P.S. Кстати, TDD — это не обязательно XP-шная практика, это самостоятельная штука. Да и то, что она избавляет от проблем, я бы, пожалуй, утверждать не стал :-)

  3. Сергей

    Честно говоря, я не понял сути проблемы, так как далек от Python. Но вот скажем я бы для вопроса "Как должны именоваться функции" завел в БД одно поле, скажем f, перечеслимого типа со значениями 1,2,3 и соответствующими текстами UPPERCASE, with_underscore, CamelCase. И сделал бы контрол, который бы отдавал следующий HTML текст:

    <label for='f1'><input type='radio' id='f3' name='f' value='1'>UPPERCASE</label>
    <label for='f2'><input type='radio' id='f3' name='f' value='2'>with_underscore</label>
    <label for='f3'><input type='radio' id='f3' name='f' value='3'>CamelCase</label>
    

    Печать "<label ...><input type='radio' id='"+id+"' name='"+name+"' value='"+значение.значение+"'>"+значение.текст+"</label>"

    Используя примерно следующий алгоритм:

    name=сгенерировать имя контрола в форме(Таблица, поле)
    pos=0
    Для всех возможных значений enuma тип(f) значение:
      id=name+pos
      ечать ""+значение.текст+""</p>
    

    Поскольку у всех контролов имя одно, то и значение извлекаем просто:

    name=сгенерировать имя контрола в форме(Таблица, поле)
    int int_field_value=request.getParameter(name);
    

    Для вопроса "какую типизацию..." аналогично

    Здесь отдельные вопросы никак не тянут на подформы - это простые поля в БД.

  4. Иван Сагалаев

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

    Сам же пример, в общем-то, к сути отношения не имеет, это просто практическая иллюстрация, причем минимизированная. Но если уж говорить о нем, то то, что вы предлагаете как раз и есть то, что Django как раз избавляет от практики (кстати, очень порочной) придумывать генерацию HTML'а в коде, хранение набора параметров в одном поле и проверку POST'а вручную. Но это совсем другая тема...

  5. Sergey Yanovitsky

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

  6. Сергей

    Блин. Я имел в виду примерно следующее:

    from django.db import models
    from django import forms
    
    class EnumField(models.IntegerField):
        def __init__(self,enum):
            models.IntegerField.__init__(self)
            self.enum=enum
    
        def prepare_field_objs_and_params(self, manipulator, name_prefix):
            params = {'validator_list': self.validator_list[:], 'member_name': name_prefix + self.attname}
            field_objs = [forms.RadioSelectField]
            params['ul_class'] = models.fields.get_ul_class(self.radio_admin)
            params['choices'] = self.get_choices_default()
            return field_objs, params
    
        def get_choices_default(self):
            ret=[]
            i=0
            for item in self.enum:
                ret.append((i,item))
                i=i+1
            return ret
    
        def get_internal_type(self):
            return "IntegerField"
    
    class Loc(models.Model):
        enum = []
        enum2 = ["UPPERCASE", "with_underscore","CamelCase"]
        functions_naming=EnumField(enum2)
        state=models.IntegerField()
    
        def title(self):
            return self.enum2[self.functions_naming]
    
        class Admin:
            list_display = ("title","state")
    
        def __str__(self):
            return "Loc-%s (%s)" % (self.functions_naming, self.state)
    
  7. Иван Сагалаев

    То есть на каждый новый вопрос надо дописывать класс? Нет, вопросы заполняет контент-менеджер в админке, и такой вариант тут совсем не пойдет.

  8. Сергей

    Это всего лишь пример.

    Что мешает в Loc вместо enum2 добавить ссылку на Enum (объект БД - по вашему вопрос описывающий вопрос и возможные варианты ответа) + вместо status хранить ссылку на объект-пользователь который ответил на данный вопрос?

    Тогда контент-менеджер редактирует объекты Enum, а ответы пользователей хранятся как Loc.

  9. Иван Сагалаев

    Я, честно говоря, не понимаю, о чем мы спорим :-)

    Статья в качестве исходных данных берет обработку форм в Django и на этом примере показывает, чем полезен duck typing.

    Вы говорите о том, что все приложение можно спроектировать по-другому, причем не зная, что это за приложение, и какие к нему требования. Соответственно спор мало того, что не по теме, но еще и совершенно беспредметен, потому что "лучше" и "проще" может быть только в контексте "зачем" и "почему".

  10. Петр

    Браво! Пример отличный, и рассказ замечательный!

    Большое спасибо :)

  11. Александр Лебедев

    Спасибо, хорошая статья.

    Показательно, что сразу же нашелся человек с позицией "зачем разбираться в нестандартном использовании библиотеки, проще написать все самому"... Не зная всех граничных условий, сложно приводить конкретные аргументы против этой точки зрения, но она почти всегда ошибочна.

  12. EntropyHacker

    Особо ценный комментарий

    Ответ на этот вопрос есть, и вполне хороший, как мне кажется, но в двух словах его касаться не хочется :-).

    Strong Typing vs. Strong Testing http://www.mindview.net/WebLog/log-0025

  13. Иван Сагалаев

    Отличная статья, спасибо! Ровно то, что я и хотел написать :-). Пожалуй, надо будет ее просто перевести...

Добавить комментарий