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 *

*
*