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