Парадигмы 1MIT, Стандартные замечания

Материал из SEWiki
Версия от 10:51, 30 сентября 2017; E.f.suvorov (обсуждение | вклад) (Заворачивайте код программы в if __name__ == "__main__" и пишите функцию main)

Перейти к: навигация, поиск

Общее

Не передавайте отдельным параметром то, что можно вычислить из остальных

Don't:

def strassen(a, b, size):
    ...

Do:

def strassen(a, b):
    size = ...

Причины:

  1. Дополнительный параметр --- дополнительное место, где можно налажать при вычислении/передаче; появляются новые инварианты, которые по-хорошему надо проверять.
  2. Сложнее отлаживать --- для тестового запуска функции требуется не просто передать массив, а ещё и его размер.
  3. Обычно такая потребность возникает при передаче кусочков матриц/массивов. Для этого обычно имеются удобные библиотечные обёртки.

Мифы:

  1. Вычислять размер каждый раз --- это медленно. На самом деле основные тормоза идут из-за сложных операций, которых много. Например, перемножение чисел или рекурсивные вызовы. Несколько обращений к памяти в не самом вложенном месте -- это нестрашно. И вообще заоптимизировать вы всегда успеете.
  2. Если не передавать размер отдельно, то нельзя работать с кусочками матриц, не копируя их. Неверно, можно просто использовать срезы. В случае с NumPy копирования во фразе mat[0:4, 0:3] не происходит - просто передаётся тот же самый кусок памяти с пометкой "используем только эту подматрицу".

Осторожно:

  1. Если вычислить этот отдельный параметр очень трудозатратно (по времени, памяти или просто по размеру кода), то может быть смысл действительно его передать. Например, если это данные, которые читаются из файла.


Проверяйте инварианты и корректность входных параметров при помощи 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

Причины:

  1. assert'ы позволяют вам в процессе отладки быть уверенными в том, что все "очевидные" предположения выполняются. Заодно получается этакая документация.
  2. Комментарии обычно не читают. Как следствие, вашу процедуру кто-нибудь легко может вызвать с неверными параметрами (например, вы сами).
  3. Комментарии устаревают в процессе разработки программы. Если же какой-нибудь assert устареет, то он сразу упадёт и его придётся изменить.

Мифы:

  1. assert не надо использовать, потому что из-за них падает программа. Программа действительно падает, однако только в тех случаях, когда она ведёт себя не так, как вы предсказали. То есть если программа упала по assert --- то либо в ней баг, либо неверно ваше понимание о том, как она работает. В любом случае это стоит исправить.

Осторожно:

  1. Если понаставить слишком много assert'ов, код становится невозможно читать.
  2. Не стоит проверять корректность интерпретатора/компилятора. Например, после строчки a = x[2] строчка assert a == x[2] бессмысленно, равно как и проверка отсортированности массива чисел после вызова sort.
  3. Не стоит писать в assert код с побочными эффектами. Некоторые языки и/или оптимизаторы (например, Java) могут просто вырезать все assert в не-отладочном режиме.
  4. Если вы пишете 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])

Причины:

  1. Если вы передаёте больше данных, чем надо обработать, получается два размера данных: "то, что передали" и "то, что надо обработать". Легко запутаться. Например, вместо print(a[start:end]) по привычке написать print(a).
  2. Если требуется обработать все переданные данные, то в вызывающем коде надо явно передавать параметры "обработай всё". Там можно ошибиться.

Мифы:

  1. При передаче будет копирование, что замедлит программу. Не всегда верно, современные языки и библиотеки могут поддерживать так называемые "срезы" ("view") --- по сути это просто обёртка над куском данных и индексами. Например, NumPy-массивы и матрицы являются такими срезами.

Осторожно:

  1. Важно понимать, происходит ли копирование. Например:
    1. В C++/Java нельзя так просто взять срез массива/std::vector --- требуются специальные отдельные классы. Скажем, std::string_view или gsl::span (из библиотеки GSL).
    2. В Python при взятии среза обычного (не numpy) списка происходит копирование.
  2. При изменении исходного массива/матрицы значение среза также меняется. 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)

Причины:

  1. При чтении кода обычно каждое имя ассоциируется с каким-то смыслом. Если этот смысл меняется в коде, то получается, что надо следить не только за именами переменных, но и ещё и за номером строчки. Это намного сложнее.
  2. Следствие: при написании и/или отладке легко перепутать, какой именно смысл сейчас имеет конкретная переменная.
  3. Если переменная используется для нескольких вещей, то одно из двух:
    1. У неё слишком общее имя (tmp, x), которое вообще не описывает то, что в этой переменной находится. Это плохо.
    2. В некоторых кусках программы содержимое переменной не соответствует тому, что в ней находится. Это тоже плохо.

Мифы:

  1. Если сделать несколько переменных, придётся придумывать нормальные имена, а это никому не нужно. Действительно, придётся придумать нормальные имена, но это довольно полезно. В первую очередь для вас: с нормальными именами переменных вы можете понять, что делает кусок кода, взглянув только на него, а не на всю программу сразу.
  2. Дополнительные переменные замедляют программу. В программе у вас вряд ли больше нескольких десятков/сотен/тысяч переменных. Одно выделение — это ни о чём. А в, скажем, C++, выделить место под все локальные переменные — это вообще одна ассемблерная инструкция в начале функции; так что от их количества ничего не зависит.
  3. Если кусок кода маленький, то можно уследить за смыслом переменной. Воспринимать строчку в отдельности от остальных мозгу проще. Если вы видите строчку вроде a = a ** 2, вы, скорее всего, по контексту догадаетесь, что теперь a — это площадь квадрата. Но через несколько строчек может использоваться другое предположение: a — это удвоенная сторона. Если вы не прочитаете эти строчки одновременно, вы не заметите эту ошибку.

Осторожно:

  1. Иногда переменных может стать слишком много, чтобы за ними уследить. Это знак, что пора либо выносить кусок кода в отдельную изолированную функцию, либо избавляться от каких-то переменных (например, встраивая "одноразовые" переменные в выражения, которые их используют).

Используйте цикл по элементам, а не по индексам

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)]

Причины:

  1. Когда вы делаете цикл по индексам, вы можете случайно ошибиться с границами: например, указав n вместо n-1 или len(foo) вместо len(bar) (это особенно хорошо происходит, когда вы копируете код или пишете много похожего).
  2. Добавление индексов делает код длиннее и отвлекает от сущности происходящего: цикл всё-таки не по индексам, а по всем элементам из списка/массива.
  3. Не все структуры поддерживают доступ по индексу, например, std::set (отсортированное множество элементов) в C++ не поддерживает, равно как и set() в Python. А вот цикл по всем элементам поддерживают все; получаем более единообразный код.

Мифы:

  1. Это работает только в Python. Неверно, это есть во всех современных языках, обычно называется "for each". В C++ (начиная с C++11) это называется range-based for.
  2. Все пишут с индексами, так понятнее. Неверно, это вопрос привычки: кто к чему привык. Тем не менее, человеческий фактор в наборе кода лучше исключать и пользоваться теми приёмами написания, которые дают меньше простора для ошибок.

Осторожно:

  1. Если очень-очень важна производительность, проверьте аккуратно, копируется ли элемент при таком итерировании. Например, в C++ вы скорее хотите использовать for (const SomeBigObject& obj : ...) а не просто for (SomeBigObject obj : ...).
  2. Иногда индексы всё-таки нужны. Например, если операция с элементом зависит от номера индекса. Тогда конкретно в Python лучше использовать функцию enumerate(), которая возвращает список пар --- элемент и его номер. Получается отличная комбинация цикла по элементам и цикла по индексам.
  3. Тем не менее, частенько "необходимость индекса" --- иллюзия. Например, если вам нужно проинициализировать массив 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())

Причины:

  1. При использовании map может получиться меньше кода.
  2. map не создаёт дополнительный список в памяти; он вычисляет значения "лениво", только когда они становятся нужны.

Мифы:

  1. map — это что-то из функционального программирования и в императивных языках не нужно. Неверно, современные языки в основном мультипарадигмальные --- позволяют писать в нескольких парадигмах. В частности, Python. Более того --- map в коде на Python довольно часто встречается, так что его вполне можно использовать и вы не удивите коллег.

Осторожно:

  1. Не стоит комбинировать много map подряд или передавать лямбду в качестве первого параметра, это уже будет довольно сложно читать. В таких случаях лучше использовать list comprehension.
  2. Аналогично, не стоит передавать list comprehension в качестве второго параметра для map.
  3. map возвращает не список, а генератор. Условно, это такой список, который можно прочитать только один раз. Это обычно подходит, если результат map сразу идёт в какую-то функцию, но не подходит, если вам нужен именно список значений. В таком случае сконструируйте список, явно вызвав list(...).
  4. Если вы присваиваете в срез уже существующего списка или матрицы, то не требуется превращать результат map в отдельно живущий список. Rule of thumb: если из результата работы map будет произведено копирование, причём ровно одно, его в список можно не превращать.
  5. np.array ругается, если ему передать в качестве аргумента результат map. Поэтому всё-таки np.array(list(map(...))) (за исключением ситуации из предыдущего пункта --- там хватит обычного list).

Не указывайте end='\n' в print

Don't:

print(1, 2, 3, end='\n')

Do:

print(1, 2, 3)

Причины:

  1. end='\n' --- это значение по умолчанию, которое весьма активно используется. Имхо, это является неким "общим знанием". Так что end='\n' просто загромождает код.
  2. Явное указания параметра, когда у него есть значение по умолчанию, заставляет думать, что в данном случае у нас передаётся другое значение. А это неправда. В результате можно повиснуть, пытаясь при чтении понять, чем же переданный '\n' отличается от значения по умолчанию ('\n').

Мифы:

  1. Значение по умолчанию может измениться. Теоретическая возможность, конечно, есть, но это сломает огромное количество программ. Поэтому так делать не будут --- обратная совместимость для языка важна.

Осторожно:

  1. Если у вас имеется много-много 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")

Причины:

  1. В мире Linux Shebang --- это общепринятый способ запуска скриптов независимо от того, на каком языке они написаны. Хорошо бы, чтобы ваш код его поддерживал. Это требуется не только в чистом Linux, но и в некоторых ситуациях под Windows.
  2. Вы не знаете заранее, где установлена нужная версия Python; где-то она может быть в /usr/bin/local/python3, где-то в /usr/bin/python, а где-то --- вообще в неочевидном месте (например, при использовании Virtualenv). По этому поводу прописывать какой-то конкретный путь в Shebang нехорошо, лучше использовать утилиту /usr/bin/env (она гарантированно есть на всех разумных системах), которая просто запустит команду с названием python3 (а где она лежит --- сама разберётся).

Мифы: пока не придумал.

Осторожно:

  1. 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()

Причина:

  1. Если вы написали какой-то скрипт и в нём имеются полезные функции, то их потом можно переиспользовать, импортировав этот файл. Но при импортировании выполняется весь код в файле; если там были что-то, кроме объявления функций --- оно выполнится. Это нехорошо, если на самом деле нужны были только функции. А условие __name__ == "__main__" выполнится только если этот скрипт был запущен, а не импортирован.
  2. Но если всё же кто-то захочет исполнить именно ваш скрипт, импортировав его, то он не сможет это сделать, если весь содержательный код содержится в if. А если он содержится в функции main --- сможет.
  3. Если вы пишете код сразу под if, то все используемые там переменные автоматически получаются глобальными. Например, следующий кусок кода напечатает 10:
def foo():
    print("a=", a)
if __name__ == "__main__":
    a=10
    foo()

Это обычно не то, что предполагается --- переменные должны быть как можно более локальными, всё глобальное должно явно объявляться.


Мифы: пока не придумал.

Осторожно: пока не придумал.