Това за което ще пиша е един подход за организиране на кода. Срещал съм тази идея под различни имена – Table-Driven Approaches и Data-Driven Programming. Много пъти съм получавал много по-прост и разширяем код с този подход. И понеже ми е малко трудно да обясня на теория, първо ще дам няколко примера и после ще коментирам.
Представете си, че имате 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)
Ето как този код може да се пренапише по-чист начин:
(‘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
ще изглежда така:
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’
Мога обаче да подходя „таблично“:
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-ване на други таблици. Мисля че няма нужда да навлизам в повече детайли:
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 код. Ето до какви резултати ще доведе „табличния“ подход:
@@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?
А, да, прав си. Там трябва да е
Мдам… чак сега видях по какво collect-ваш, така че последния въпрос в пост-а ми е неуместен 🙂
‘Убав пост. почти 1:1 такова парче код имам някъде из последния рейлс проект – точно в същия кейс (търсене с различни комбинации).