Table-Driven

Това за което ще пиша е един подход за организиране на кода. Срещал съм тази идея под различни имена – Table-Driven Approaches и Data-Driven Programming. Много пъти съм получавал много по-прост и разширяем код с този подход. И понеже ми е малко трудно да обясня на теория, първо ще дам няколко примера и после ще коментирам.

Представете си, че имате CSV файл, който съдържа информация за музикалните ви албуми. Данните включват изпълнителя, името, дължина, година, стил и рейтинг. Искаме да прочетем това в обект. Записите изглеждат горе-долу така:

Pearl Jam, Ten, 11, 53:24, 1991, Grunge, 10

Първото нещо което ми хрумна е следния код:

import csv

class Album: pass

def inSeconds(length):
    minutes, seconds = map(int, length.split(‘:’))
    return minutes * 60 + seconds

albums = []
for row in csv.reader(open(‘albums.csv’)):
    assert len(row) == 7, "Invalid number of values per row: %r" % row

    album = Album()
    album.artist = row[0]
    album.name = row[1]
    album.tracks = int(row[2])
    album.length = inSeconds(row[3])
    album.year = int(row[4])
    album.genre = row[5]
    album.rating = int(row[6])

    albums.append(album)

Ето как този код може да се пренапише по-чист начин:

fields = ((‘artist’, str), (‘name’, str), (‘tracks’, int),
    (‘length’, inSeconds), (‘year’, int), (‘genre’, str), (‘rating’, int))
albums = []
for row in csv.reader(open(‘albums.csv’)):
    assert len(fields) == len(row)

    album = Album()
    for (attr, kind), value in zip(fields, row):
        setattr(album, attr, kind(value))

    albums.append(album)

Следващото е един изсмукан от пръстите пример, който използва тази идея за организиране на логика. Представете си система, за която е вярно следното:

  • Има обикновени потребители и супер-потребители.
  • Операциите които те могат да изпълняват са обикновени и привилигеровани.
  • Изпълняването на всяка операция изисква от потребителя да си въведе паролата отново (ask). Супер-потребителите могат да изпълняват непривилигеровани операции без да въвеждат парола (allow).
  • Операциите могат да се изпълняват отдалечено (през SSH от вкъщи).
  • Нормалните потребители не могат да изпълняват привилигеровани операции отдалечено.
  • Супер-потребителите губят правото си да изпълняват непривилигировани команди без парола, когато работят отдалечено.

Малко завъртяно, но се случва в живия живот. Искам да напиша функция, която да връща ‘ask’, ‘allow’ или ‘deny’ взависимост от сценария. Стандартния подход с if ще изглежда така:

def response(session, operation):
    if (session.user.isSuperuser()):
        if (!operation.isPriviledged() and !operation.isRemote()):
            return ‘allow’
        else:
            return ‘ask’
    else:
        if (session.isRemote() and operation.isPriviledged()):
            return ‘deny’
        else:
            return ‘ask’

Мога обаче да подходя „таблично“:

def response(session, operation):
    rules = {
        (False, True,  True ): ‘deny’,
        (True,  False, False): ‘allow’,
    }
    scenario = (session.user.isSuperuser(), session.isRemote(), operation.isPriviledged())
    return rules.get(scenario, ‘ask’)

И двата варианта ми се струват трудно четими – при първият трябва внимателно да е замисля над вложените if-ове за да разбера какво става, докато във втория трябва да схвана врътката с речника. Все пак, втория вариант има голямо предимство – специалните случаи (резултата различен от ‘ask’) са очевидни.

И като трети пример на идеята, ще дам едно генериране на SQL от реална система. Това което трябва да се реализира е търсене по форма. Информацията от нея идва в хеш criteria. Търсенето на някои от полетата изисква join-ване на други таблици. Мисля че няма нужда да навлизам в повече детайли:

def self.search(criteria)
  condition = ‘1’
  variables = {}
  joins = []

  unless criteria[‘web_site_ids’].blank?
    condition += " AND web_site_ids IN (:web_site_ids)"
    variables[:web_site_ids] = criteria[‘web_site_ids’]
    joins << ‘web_site’
  end

  unless criteria[‘color_ids’].blank?
    condition += " AND color_id IN (:color_ids)"
    variables[:color_ids] = criteria[‘color_ids’]
    joins << ‘color’
  end

  unless criteria[‘type_ids’].blank?
    condition += " AND type_id IN (:type_ids)"
    variables[:type_ids] = criteria[‘type_ids’]
    joins << ‘type’
  end

  unless criteria[‘medium_ids’].blank?
    condition += " AND medium_id IN (:medium_ids)"
    variables[:medium_ids] = criteria[‘medium_ids’]
    joins << ‘image_media’
  end

  unless criteria[‘product_name’].blank?
    condition += " AND product_name = :product_name"
    variables[:product_name] = criteria[‘product_name’]
  end

  unless criteria[‘artist_id’].blank?
    condition += " AND artist_id = :artist_id"
    variables[:artist_id] = criteria[‘artist_id’]
  end

  unless criteria[‘collection_id’].blank?
    condition += " AND collection_id = :collection_id"
    variables[:collection_id] = criteria[‘collection_id’]
  end

  unless criteria[‘subject_id’].blank?
    condition += " AND subject_id = :subject_id"
    variables[:subject_id] = criteria[‘subject_id’]
  end

  unless criteria[‘licensed’].blank?
    condition += " AND publisher_id IS NOT NULL OR sub_publisher_id IS NOT NULL"
    joins << ‘image_media’
  end

  Image.find :all, :include => joins.uniq, :conditions => [conditions, variables]
end

Грозно, нали? И изглежда като нещо, което ще намерите в PHP код. Ето до какви резултати ще доведе „табличния“ подход:

def self.search(criteria)
  @@conditions ||= {
    ‘web_site_ids’      => ‘web_site_id IN (:web_site_ids)’,
    ‘color_ids’         => ‘color_id IN (:color_ids)’,
    ‘type_ids’          => ‘type_id IN (:type_ids)’,
    ‘medium_ids’        => ‘medium_id IN (:medium_ids)’,
    ‘product_name’      => ‘product_name = :product_name’,
    ‘title’             => ‘title = :title’,
    ‘artist_id’         => ‘artist_id = :artist_id’,
    ‘collection_id’     => ‘collection_id = :collection_id’,
    ‘subject_id’        => ‘subject_id = :subject_id’,
    ‘licensed’          => ‘publisher_id IS NOT NULL OR sub_publisher_id IS NOT NULL’,
  }
  @@joins ||= {
    ‘web_site_ids’      => ‘web_site’,
    ‘color_ids’         => ‘color’,
    ‘type_ids’          => ‘type’,
    ‘medium_ids’        => ‘image_media’,
    ‘licensed’          => ‘image_media’,
  }

  conditions = criteria.collect { |key, value| @@conditions[key] }.compact
  joins = criteria.keys.collect { |key| @@joins[key] }.compact.uniq

  Image.find :all, :include => joins, :conditions => [conditions.join(‘ AND ‘), criteria]
end

Разбирате идеята. Всеки пък, когато кодът ви започне да изглежда като повторение на някакви конструкции, помислете как да го пренапишете таблично. Ето какви са моите впечатления от предимствата:

  • Разделяте по чист начин бизнес логиката от синтактичния шум – обърнете внимание как и в трите примера, „стандартния“ подход разхвърля специфичното за проблема познание из целия код. Табличното пренаписване го държи на едно място – в началото на кода. Така много по-лесно променяте „бизнес правилата“ или схемата за обработването им.
  • Кодът става по-четим – като виждате, вторите варианти изглеждат много по-добре.
  • Избягва повторение на код – в първия пример има едно много и коварно повторение на код – редовете album.атрибут = row[индекс]. Дори да не е директен copy/paste, това е повторение и трябва да се избягва. Както сами виждате, решението е доста по-елегантно.
  • Допускате по-малко грешки и разширявате по-лесно – това обикновено следва от горните три.

P.S.: Генерацията на SQL заявката не е оптимална. Може да се направи още по-читаво, ако условието и join моделите бяха на едно място, а не в отделен хеш. Но това се сетих след като написах кода.

P.P.S.: Като заигравка с втория пример, може да си направите клас AnyClass, който да служи както за True, така и за False. Обаче няма да е тривиално, понеже трябва да се хешира адекватно. Някой има ли хитри идеи?

4 thoughts on “Table-Driven

  1. Хммм… В примера на Ruby сигурен ли си, че няма някаква грешка? Предполагам, че параметъра criteria, ти е хеш със параметрите за SQL-а (т.е. criteria_params по-долу). Тогава, когато правиш collect-ите не би ли трябвало да проверяваш дали имаш съотвения параметър в criteria?

  2. Мдам… чак сега видях по какво collect-ваш, така че последния въпрос в пост-а ми е неуместен 🙂

  3. ‘Убав пост. почти 1:1 такова парче код имам някъде из последния рейлс проект – точно в същия кейс (търсене с различни комбинации).

Вашият коментар

Вашият имейл адрес няма да бъде публикуван. Задължителните полета са отбелязани с *