Това за което ще пиша е един подход за организиране на кода. Срещал съм тази идея под различни имена – 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)
На пръв поглед не изглежда зле. Но само на пръв. Познанието за броя полета в един запис е неприятно разпръснато между assert-а и седемте реда присвояване. Да не говорим, че цялото нещо изглежда като повторение на код. Замислете се какво става, ако трябва да се промени формата – да добавите лейбъл преди годината. Добавяне на нов ред, пренареждане на индекси и дано да се сетите да обновите assert-а.
Ето как този код може да се пренапише по-чист начин:
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)
Така информацията за реда и типа на полетата е събрано на едно място – в n-орката fields. Забележете, че тя е единственото нещо което трябва да се промени при нов формат. Така познанието съсредоточено в едно място.
Следващото е един изсмукан от пръстите пример, който използва тази идея за организиране на логика. Представете си система, за която е вярно следното:
- Има обикновени потребители и супер-потребители.
- Операциите които те могат да изпълняват са обикновени и привилигеровани.
- Изпълняването на всяка операция изисква от потребителя да си въведе паролата отново (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. Обаче няма да е тривиално, понеже трябва да се хешира адекватно. Някой има ли хитри идеи?
Хммм… В примера на Ruby сигурен ли си, че няма някаква грешка? Предполагам, че параметъра criteria, ти е хеш със параметрите за SQL-а (т.е. criteria_params по-долу). Тогава, когато правиш collect-ите не би ли трябвало да проверяваш дали имаш съотвения параметър в criteria?
А, да, прав си. Там трябва да е
criteria, неcriteria_params. Обновил съм го, погледни сега.Мдам… чак сега видях по какво collect-ваш, така че последния въпрос в пост-а ми е неуместен
‘Убав пост. почти 1:1 такова парче код имам някъде из последния рейлс проект – точно в същия кейс (търсене с различни комбинации).