Парадигмы 1MIT, Стандартные замечания
Содержание
- 1 Общее
- 1.1 Не передавайте отдельным параметром то, что можно вычислить из остальных
- 1.2 Проверяйте инварианты и корректность входных параметров при помощи assert
- 1.3 Используйте срезы вместо передачи индексов
- 1.4 Не используйте одну переменную для нескольких вещей
- 1.5 Используйте цикл по элементам, а не по индексам
- 2 Python
Общее
Не передавайте отдельным параметром то, что можно вычислить из остальных
Don't:
def strassen(a, b, size): ...
Do:
def strassen(a, b): size = ...
Причины:
- Дополнительный параметр --- дополнительное место, где можно налажать при вычислении/передаче; появляются новые инварианты, которые по-хорошему надо проверять.
- Сложнее отлаживать --- для тестового запуска функции требуется не просто передать массив, а ещё и его размер.
- Обычно такая потребность возникает при передаче кусочков матриц/массивов. Для этого обычно имеются удобные библиотечные обёртки.
Мифы:
- Вычислять размер каждый раз --- это медленно. На самом деле основные тормоза идут из-за сложных операций, которых много. Например, перемножение чисел или рекурсивные вызовы. Несколько обращений к памяти в не самом вложенном месте -- это нестрашно. И вообще заоптимизировать вы всегда успеете.
- Если не передавать размер отдельно, то нельзя работать с кусочками матриц, не копируя их. Неверно, можно просто использовать срезы. В случае с NumPy копирования во фразе
mat[0:4, 0:3]
не происходит - просто передаётся тот же самый кусок памяти с пометкой "используем только эту подматрицу".
Осторожно:
- Если вычислить этот отдельный параметр очень трудозатратно (по времени, памяти или просто по размеру кода), то может быть смысл действительно его передать. Например, если это данные, которые читаются из файла.
Проверяйте инварианты и корректность входных параметров при помощи assert
Don't:
# as и bs должны быть одной длины def very_complex_algorithm(as, bs): ... # x + y тут должно быть равно 4 ...
Do:
def very_complex_algorithm(as, bs): assert len(as) == len(bs) ... assert x + y == 4
Причины:
-
assert
'ы позволяют вам в процессе отладки быть уверенными в том, что все "очевидные" предположения выполняются. Заодно получается этакая документация. - Комментарии обычно не читают. Как следствие, вашу процедуру кто-нибудь легко может вызвать с неверными параметрами (например, вы сами).
- Комментарии устаревают в процессе разработки программы. Если же какой-нибудь
assert
устареет, то он сразу упадёт и его придётся изменить.
Мифы:
-
assert
не надо использовать, потому что из-за них падает программа. Программа действительно падает, однако только в тех случаях, когда она ведёт себя не так, как вы предсказали. То есть если программа упала поassert
--- то либо в ней баг, либо неверно ваше понимание о том, как она работает. В любом случае это стоит исправить.
Осторожно:
- Если понаставить слишком много
assert
'ов, код становится невозможно читать. - Не стоит проверять корректность интерпретатора/компилятора. Например, после строчки
a = x[2]
строчкаassert a == x[2]
бессмысленно, равно как и проверка отсортированности массива чисел после вызоваsort
. - Не стоит писать в
assert
код с побочными эффектами. Некоторые языки и/или оптимизаторы (например, Java) могут просто вырезать всеassert
в не-отладочном режиме. - Если вы пишете production-сервис для Яндекс/Google, то падение программы нежелательно и вместо
assert
следует использовать другие конструкции и восстанавливать работу программы.
Используйте срезы вместо передачи индексов
Don't:
a = np.array(...) ... printMatrix(m, 5) printArray(a, 2, 10)
Do:
a = np.array(...) ... printMatrix(m[:5, :5]) printArray(a[2:10])
Причины:
- Если вы передаёте больше данных, чем надо обработать, получается два размера данных: "то, что передали" и "то, что надо обработать". Легко запутаться. Например, вместо
print(a[start:end])
по привычке написатьprint(a)
. - Если требуется обработать все переданные данные, то в вызывающем коде надо явно передавать параметры "обработай всё". Там можно ошибиться.
Мифы:
- При передаче будет копирование, что замедлит программу. Не всегда верно, современные языки и библиотеки могут поддерживать так называемые "срезы" ("view") --- по сути это просто обёртка над куском данных и индексами. Например, NumPy-массивы и матрицы являются такими срезами.
Осторожно:
- Важно понимать, происходит ли копирование. Например:
- В C++/Java нельзя так просто взять срез массива/
std::vector
--- требуются специальные отдельные классы. Скажем,std::string_view
илиgsl::span
(из библиотеки GSL). - В Python при взятии среза обычного (не numpy) списка происходит копирование.
- В C++/Java нельзя так просто взять срез массива/
- При изменении исходного массива/матрицы значение среза также меняется. Rule of thumb: не сохраняйте срезы в какие-либо поля или долгоживущие переменные. Если требуется --- сделайте явную копию.
Не используйте одну переменную для нескольких вещей
Don't:
tmp = int(input()) tmp = tmp ** 2 tmp = solve(tmp) print(tmp)
Do:
side = int(input()) area = side ** 2 answer = solve(area) print(answer)
Причины:
- При чтении кода обычно каждое имя ассоциируется с каким-то смыслом. Если этот смысл меняется в коде, то получается, что надо следить не только за именами переменных, но и ещё и за номером строчки. Это намного сложнее.
- Следствие: при написании и/или отладке легко перепутать, какой именно смысл сейчас имеет конкретная переменная.
- Если переменная используется для нескольких вещей, то одно из двух:
- У неё слишком общее имя (
tmp
,x
), которое вообще не описывает то, что в этой переменной находится. Это плохо. - В некоторых кусках программы содержимое переменной не соответствует тому, что в ней находится. Это тоже плохо.
- У неё слишком общее имя (
Мифы:
- Если сделать несколько переменных, придётся придумывать нормальные имена, а это никому не нужно. Действительно, придётся придумать нормальные имена, но это довольно полезно. В первую очередь для вас: с нормальными именами переменных вы можете понять, что делает кусок кода, взглянув только на него, а не на всю программу сразу.
- Дополнительные переменные замедляют программу. В программе у вас вряд ли больше нескольких десятков/сотен/тысяч переменных. Одно выделение — это ни о чём. А в, скажем, C++, выделить место под все локальные переменные — это вообще одна ассемблерная инструкция в начале функции; так что от их количества ничего не зависит.
- Если кусок кода маленький, то можно уследить за смыслом переменной. Воспринимать строчку в отдельности от остальных мозгу проще. Если вы видите строчку вроде
a = a ** 2
, вы, скорее всего, по контексту догадаетесь, что теперьa
— это площадь квадрата. Но через несколько строчек может использоваться другое предположение:a
— это удвоенная сторона. Если вы не прочитаете эти строчки одновременно, вы не заметите эту ошибку.
Осторожно:
- Иногда переменных может стать слишком много, чтобы за ними уследить. Это знак, что пора либо выносить кусок кода в отдельную изолированную функцию, либо избавляться от каких-то переменных (например, встраивая "одноразовые" переменные в выражения, которые их используют).
Используйте цикл по элементам, а не по индексам
Don't:
for i in range(n): if vals[i] > 2: print("Found!") for i in range(len(vals)): if vals[i] > 2: print("Found!") [as[i] ** 2 + 3 for i in range(len(as))] [as[i] + bs[i] for i in range(n)]
Do:
for val in vals: if val > 2: print("Found!") [a ** 2 + 3 for a in as] [a + b for a, b in zip(as, bs)]
Причины:
- Когда вы делаете цикл по индексам, вы можете случайно ошибиться с границами: например, указав
n
вместоn-1
илиlen(foo)
вместоlen(bar)
(это особенно хорошо происходит, когда вы копируете код или пишете много похожего). - Добавление индексов делает код длиннее и отвлекает от сущности происходящего: цикл всё-таки не по индексам, а по всем элементам из списка/массива.
- Не все структуры поддерживают доступ по индексу, например,
std::set
(отсортированное множество элементов) в C++ не поддерживает, равно как иset()
в Python. А вот цикл по всем элементам поддерживают все; получаем более единообразный код.
Мифы:
- Это работает только в Python. Неверно, это есть во всех современных языках, обычно называется "for each". В C++ (начиная с C++11) это называется range-based for.
- Все пишут с индексами, так понятнее. Неверно, это вопрос привычки: кто к чему привык. Тем не менее, человеческий фактор в наборе кода лучше исключать и пользоваться теми приёмами написания, которые дают меньше простора для ошибок.
Осторожно:
- Если очень-очень важна производительность, проверьте аккуратно, копируется ли элемент при таком итерировании. Например, в C++ вы скорее хотите использовать
for (const SomeBigObject& obj : ...)
а не простоfor (SomeBigObject obj : ...)
. - Иногда индексы всё-таки нужны. Например, если операция с элементом зависит от номера индекса. Тогда конкретно в Python лучше использовать функцию
enumerate()
, которая возвращает список пар --- элемент и его номер. Получается отличная комбинация цикла по элементам и цикла по индексам. - Тем не менее, частенько "необходимость индекса" --- иллюзия. Например, если вам нужно проинициализировать массив
B
из массиваA
, то лучше воспользоватьсяmap
или, в крайнем случае, сделатьB
изначально пустым, а потом добавлять в конец элементы. Необязательно выделять весьB
сразу, а потом писать в определённые индексы (например, в C++ есть методvector::reserve
, чтобы такое работало быстро).
Python
Иногда стоит использовать map
вместо list comprehension
Don't:
xs = [int(x) for x in line.split()] print(", ".join(str(x) for x in xs)) ys[2:4] = [int(x) for x in line.split()]
Do:
xs = list(map(int, line.split()) print(", ".join(map(str, xs))) ys[2:4] = map(int, line.split())
Причины:
- При использовании
map
может получиться меньше кода. -
map
не создаёт дополнительный список в памяти; он вычисляет значения "лениво", только когда они становятся нужны.
Мифы:
-
map
— это что-то из функционального программирования и в императивных языках не нужно. Неверно, современные языки в основном мультипарадигмальные --- позволяют писать в нескольких парадигмах. В частности, Python. Более того ---map
в коде на Python довольно часто встречается, так что его вполне можно использовать и вы не удивите коллег.
Осторожно:
- Не стоит комбинировать много
map
подряд или передавать лямбду в качестве первого параметра, это уже будет довольно сложно читать. В таких случаях лучше использовать list comprehension. - Аналогично, не стоит передавать list comprehension в качестве второго параметра для
map
. -
map
возвращает не список, а генератор. Условно, это такой список, который можно прочитать только один раз. Это обычно подходит, если результатmap
сразу идёт в какую-то функцию, но не подходит, если вам нужен именно список значений. В таком случае сконструируйте список, явно вызвавlist(...)
. - Если вы присваиваете в срез уже существующего списка или матрицы, то не требуется превращать результат
map
в отдельно живущий список. Rule of thumb: если из результата работыmap
будет произведено копирование, причём ровно одно, его в список можно не превращать. -
np.array
ругается, если ему передать в качестве аргумента результатmap
. Поэтому всё-такиnp.array(list(map(...)))
(за исключением ситуации из предыдущего пункта --- там хватит обычногоlist
).
Не указывайте end='\n'
в print
Don't:
print(1, 2, 3, end='\n')
Do:
print(1, 2, 3)
Причины:
-
end='\n'
--- это значение по умолчанию, которое весьма активно используется. Имхо, это является неким "общим знанием". Так чтоend='\n'
просто загромождает код. - Явное указания параметра, когда у него есть значение по умолчанию, заставляет думать, что в данном случае у нас передаётся другое значение. А это неправда. В результате можно повиснуть, пытаясь при чтении понять, чем же переданный
'\n'
отличается от значения по умолчанию ('\n'
).
Мифы:
- Значение по умолчанию может измениться. Теоретическая возможность, конечно, есть, но это сломает огромное количество программ. Поэтому так делать не будут --- обратная совместимость для языка важна.
Осторожно:
- Если у вас имеется много-много
print
подряд, и у каких-тоend
имеет необычное значение, а у каких-то --- значение по умолчанию, может иметь смысл для единообразия выставить его явно у всех сразу. Тогда при чтении не будет возникать ощущение "а, тут у всех выставлено в необычное значение".
Добавляйте правильный shebang
Shebang (первая строка файла, начинающаяся с #!
) --- это важный элемент скрипта в Unix-системах, в том числе Linux. Он позволяет сделать сам файл скрипта исполняемым и вместо python3 code.py
писать просто ./code.py
, как если бы code.py
был исполняемым файлом. Работает так: если вы пытаетесь исполнить файл и у него первые два символа оказываются равны #!
, то ОС просто запускает строчку, написанную после этих двух символов, приписав ей в конец название файла. Обычно это выливается в запуск интерпретатора.
Don't:
#!/usr/bin/python print("Hello World")
Don't:
#!/usr/local/bin/python3 print("Hello World")
Do:
#!/usr/bin/env python3 print("Hello World")
Причины:
- В мире Linux Shebang --- это общепринятый способ запуска скриптов независимо от того, на каком языке они написаны. Хорошо бы, чтобы ваш код его поддерживал. Это требуется не только в чистом Linux, но и в некоторых ситуациях под Windows.
- Вы не знаете заранее, где установлена нужная версия Python; где-то она может быть в
/usr/bin/local/python3
, где-то в/usr/bin/python
, а где-то --- вообще в неочевидном месте (например, при использовании Virtualenv). По этому поводу прописывать какой-то конкретный путь в Shebang нехорошо, лучше использовать утилиту/usr/bin/env
(она гарантированно есть на всех разумных системах), которая просто запустит команду с названиемpython3
(а где она лежит --- сама разберётся).
Мифы: пока не придумал.
Осторожно:
- Shebang предназначен только для скриптов, которые можно запускать. Не стоит добавлять его в код какой-нибудь питоновской библиотеки. Rule of thumb: если у команды
python your_code.py
есть адекватный результат работы, то Shebang вyour_code.py
нужен. Иначе --- не нужен.
Заворачивайте код программы в if __name__ == "__main__"
и пишите функцию main
Don't:
#!/usr/bin/env python3 print("Hello World")
Do:
#!/usr/bin/env python3 def main(): print("Hello World")
if __name__ == "__main__": main()