Table-Driven

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

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

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

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

Python [Show Plain Code]:
  1. import csv
  2.  
  3. class Album: pass
  4.  
  5. def inSeconds(length):
  6.     minutes, seconds = map(int, length.split(‘:’))
  7.     return minutes * 60 + seconds
  8.  
  9. albums = []
  10. for row in csv.reader(open(‘albums.csv’)):
  11.     assert len(row) == 7, "Invalid number of values per row: %r" % row
  12.  
  13.     album = Album()
  14.     album.artist = row[0]
  15.     album.name = row[1]
  16.     album.tracks = int(row[2])
  17.     album.length = inSeconds(row[3])
  18.     album.year = int(row[4])
  19.     album.genre = row[5]
  20.     album.rating = int(row[6])
  21.  
  22.     albums.append(album)

На пръв поглед не изглежда зле. Но само на пръв. Познанието за броя полета в един запис е неприятно разпръснато между assert-а и седемте реда присвояване. Да не говорим, че цялото нещо изглежда като повторение на код. Замислете се какво става, ако трябва да се промени формата—да добавите лейбъл преди годината. Добавяне на нов ред, пренареждане на индекси и дано да се сетите да обновите assert-а.

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

Python [Show Plain Code]:
  1. fields = ((‘artist’, str), (‘name’, str), (‘tracks’, int),
  2.     (‘length’, inSeconds), (‘year’, int), (‘genre’, str), (‘rating’, int))
  3. albums = []
  4. for row in csv.reader(open(‘albums.csv’)):
  5.     assert len(fields) == len(row)
  6.  
  7.     album = Album()
  8.     for (attr, kind), value in zip(fields, row):
  9.         setattr(album, attr, kind(value))
  10.  
  11.     albums.append(album)

Така информацията за реда и типа на полетата е събрано на едно място—в n-орката fields. Забележете, че тя е единственото нещо което трябва да се промени при нов формат. Така познанието съсредоточено в едно място.

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

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

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

Python [Show Plain Code]:
  1. def response(session, operation):
  2.     if (session.user.isSuperuser()):
  3.         if (!operation.isPriviledged() and !operation.isRemote()):
  4.             return ‘allow’
  5.         else:
  6.             return ‘ask’
  7.     else:
  8.         if (session.isRemote() and operation.isPriviledged()):
  9.             return ‘deny’
  10.         else:
  11.             return ‘ask’

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

Python [Show Plain Code]:
  1. def response(session, operation):
  2.     rules = {
  3.         (False, TrueTrue ): ‘deny’,
  4.         (TrueFalse, False): ‘allow’,
  5.     }
  6.     scenario = (session.user.isSuperuser(), session.isRemote(), operation.isPriviledged())
  7.     return rules.get(scenario, ‘ask’)

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

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

  1. def self.search(criteria)
  2.   condition = ‘1
  3.   variables = {}
  4.   joins = []
  5.  
  6.   unless criteria[‘web_site_ids’].blank?
  7.     condition += " AND web_site_ids IN (:web_site_ids)"
  8.     variables[:web_site_ids] = criteria[‘web_site_ids’]
  9.     joins << ‘web_site’
  10.   end
  11.  
  12.   unless criteria[‘color_ids’].blank?
  13.     condition += " AND color_id IN (:color_ids)"
  14.     variables[:color_ids] = criteria[‘color_ids’]
  15.     joins << ‘color’
  16.   end
  17.  
  18.   unless criteria[‘type_ids’].blank?
  19.     condition += " AND type_id IN (:type_ids)"
  20.     variables[:type_ids] = criteria[‘type_ids’]
  21.     joins << ‘type’
  22.   end
  23.  
  24.   unless criteria[‘medium_ids’].blank?
  25.     condition += " AND medium_id IN (:medium_ids)"
  26.     variables[:medium_ids] = criteria[‘medium_ids’]
  27.     joins << ‘image_media’
  28.   end
  29.  
  30.   unless criteria[‘product_name’].blank?
  31.     condition += " AND product_name = :product_name"
  32.     variables[:product_name] = criteria[‘product_name’]
  33.   end
  34.  
  35.   unless criteria[‘artist_id’].blank?
  36.     condition += " AND artist_id = :artist_id"
  37.     variables[:artist_id] = criteria[‘artist_id’]
  38.   end
  39.  
  40.   unless criteria[‘collection_id’].blank?
  41.     condition += " AND collection_id = :collection_id"
  42.     variables[:collection_id] = criteria[‘collection_id’]
  43.   end
  44.  
  45.   unless criteria[’subject_id’].blank?
  46.     condition += " AND subject_id = :subject_id"
  47.     variables[:subject_id] = criteria[’subject_id’]
  48.   end
  49.  
  50.   unless criteria[‘licensed’].blank?
  51.     condition += " AND publisher_id IS NOT NULL OR sub_publisher_id IS NOT NULL"
  52.     joins << ‘image_media’
  53.   end
  54.  
  55.   Image.find :all, :include => joins.uniq, :conditions => [conditions, variables]
  56. end

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

  1. def self.search(criteria)
  2.   @@conditions ||= {
  3.     ‘web_site_ids’      => ‘web_site_id IN (:web_site_ids)‘,
  4.     ‘color_ids’         => ‘color_id IN (:color_ids)‘,
  5.     ‘type_ids’          => ‘type_id IN (:type_ids)‘,
  6.     ‘medium_ids’        => ‘medium_id IN (:medium_ids)‘,
  7.     ‘product_name’      => ‘product_name = :product_name’,
  8.     ‘title’             => ‘title = :title’,
  9.     ‘artist_id’         => ‘artist_id = :artist_id’,
  10.     ‘collection_id’     => ‘collection_id = :collection_id’,
  11.     ’subject_id’        => ’subject_id = :subject_id’,
  12.     ‘licensed’          => ‘publisher_id IS NOT NULL OR sub_publisher_id IS NOT NULL’,
  13.   }
  14.   @@joins ||= {
  15.     ‘web_site_ids’      => ‘web_site’,
  16.     ‘color_ids’         => ‘color’,
  17.     ‘type_ids’          => ‘type’,
  18.     ‘medium_ids’        => ‘image_media’,
  19.     ‘licensed’          => ‘image_media’,
  20.   }
  21.  
  22.   conditions = criteria.collect { |key, value| @@conditions[key] }.compact
  23.   joins = criteria.keys.collect { |key| @@joins[key] }.compact.uniq
  24.  
  25.   Image.find :all, :include => joins, :conditions => [conditions.join(AND), criteria]
  26. end

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

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

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

4 Comments

  1. Валентин Михов
    Posted October 26, 2007 at 11:19 am | Permalink

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

  2. Posted October 26, 2007 at 12:58 pm | Permalink

    А, да, прав си. Там трябва да е criteria, не criteria_params. Обновил съм го, погледни сега.

  3. Валентин Михов
    Posted October 26, 2007 at 1:49 pm | Permalink

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

  4. Posted October 29, 2007 at 12:19 am | Permalink

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

Post a Comment

Your email is never published nor shared. Required fields are marked *

*
*