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

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

Содержание

Общее

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

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 или Visual C++) могут просто вырезать все 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, чтобы такое работало быстро).

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

Don't:

a = int(input())
b = int(input())
tmp = a * b
tmp2 = a + b
print(tmp, tmp2)

for arr in matrix:
    print(*arr)

 eabs = ...

Do:

width = int(input())
height = int(input())
area = width * height
half_perimeter = width + height
print(area, half_perimeter)

for row in matrix:
    print(*row)

edges_a_to_b = ...

Причины:

  1. Названия переменных должны помогать читать код. А переменные нужны, чтобы хранить в них данные. Про данные важно знать лишь то, что это за данные.
  2. Обычно неважно, откуда взялись данные, если они уже лежат в переменной. А иногда данные можно получить вообще несколькими способами.
  3. Переменная --- это то, как общаются разные части программы. Если вы поменяли первую часть и полностью изменили логику, но при этом сохранили смысл переменной, то хорошо бы, чтобы её имя осталось прежним, чтобы вторая часть могла её точно так же использовать. А вот если смысл поменялся --- хорошо бы, чтобы имя тоже поменялось, чтобы вторая часть гарантированно не скомпилировалась.
  4. Если переменная называется "общим" и неконкретным именем (вроде tmp, x, a), то можно уже через несколько строчек легко забыть или перепутать, что же в ней лежит.
  5. В языках, где переменные объявляются неявно (вроде Python, но не C++/Java), можно случайно назвать две переменных одинаково. Если у них разные названия, то этого, очевидно, не произойдёт. А если переменных две, то у них точно разный смысл, следовательно, по этой рекомендации должны быть разные названия.

Мифы:

  1. Переменная должна называться tmp, потому что она действительно временная. То, что переменная временная, не отменяет того, что в ней лежат какие-то полезные данные (иначе зачем она вообще нужна?). А эти данные можно как-то описать, следовательно, можно сделать нормальное название для переменной.
  2. Я добавил(а) две временных переменных с какими-то бессмысленными значениями, потому что иначе получалась слишком длинная строчка. Есть два решения, хотя бы одно из которых строго лучше:
    1. Выделить из сложной длинной строчки другие подвыражения, у которых есть смысл. Заодно повысится читаемость. Это решение хорошо для тех случаев, когда у вас длинная строчка комбинирует много всего; обычно это "много всего" состоит из частей попроще.
    2. Пункт первый плюс расставить в сложной строчке переводы строк и отступы. Практически все style guide это позволяют, хоть и не рекомендуют. Это решение хорошо для тех случаев, когда у вас длинная строчка является просто записью какой-то длинной математической формулы. Тогда, например, можно назвать переменные так же, как на бумажке.
  3. Если переменная используется в соседних строчках, то её можно назвать как угодно. См. предыдущий пункт.
  4. Ну что тут непонятного --- в переменной foo_bar лежит результат foo(bar). Действительно, по названию переменной может быть просто определить, откуда она взялась. Тем не менее, вычисления всё-таки производятся с тем, что в этой переменной лежит. А лежат там данные. Если программист захочет, например, вывести эту переменную и проверить, что в ней находится, ему придётся понять, что это за данные. Если в названии переменной указано, что там лежит, это сделать просто. Если там указано, как переменная была получена --- придётся разбираться с этой функцией foo и переменной bar
  5. Ну уж одно-то название программист в состоянии запомнить --- оно всего в двух местах программы используется. Когда программист написал этот код пять минут назад --- да. Когда этот код читает кто-то ещё, он(а) вряд ли будет выписывать на бумажку все переменные. Более того --- обычно код читается не целиком, а кусками наименьшего размера, в которых ещё что-то понятно. Чем меньше кусочек, который надо прочитать для понимания, тем в некотором смысле "лучше" код (хоть и не всегда).
  6. Название eabs прекрасно описывает содержимое --- прямые рёбра графа (edges from A to B), причём это их список. Мнемоника хороша лишь тогда, когда вы уже примерно в курсе, что должно быть в переменной, и лишь добавляете в имя уточнение --- "рёбра из A в B". Но если вы не знаете, что в этой переменной вообще могут рёбра лежат, это название бесполезно. В решениях задач по алгоритмам и тех программах, которые живут только у вас и всего несколько часов --- ок. В домашках по парадигмам и вообще любом долгоживущем коде --- не ок.

Осторожно:

  1. В некоторых местах принято называть переменные коротко и конкретно, самый яркий пример --- цикл for по индексам массива, там обычно называют переменную i. Но даже в этом случае возникают проблемы с вложенными циклами (потому что переменные начинают похоже называться). Поэтому рекомендуется всё-таки использовать циклы по элементам.
  2. Если у вас имеется некая математическая задача и в ней фигурируют однобуквенные переменные, то лучше их ровно так и перенести в программу --- будет проще искать расхождения между кодом и математическими выкладками. Например, при реализации алгоритма Штрассена. Или при считывании данных в решении задачи по алгоритмам.
  3. Не переусердствуйте с длиной названий переменных; не стоит там описывать вообще весь жизненный цикл данных или подробно описывать инварианты. Тут, увы, требуется некий "вкус", "опыт", и это даже можно назвать "искусством".

Не пытайтесь подавлять ошибки в программах

Don't:

try:
    x = arr[10]
except:
    x = 100500

def divide(a, b):
    if b == 0:
        return 100
    else:
        return a // b

# assert foo(2) == 4  # Иногда падает, лень разбираться

Do:

x = arr[10]

def divide(a, b):
    return a // b

# assert foo(-1) == -1  # Падает, но такое поведение в задаче допустимо


Причины:

  1. Программы должны вести себя максимально предсказуемо, особенно в случаях, когда что-то пошло не так. Обычно программист всё-таки ожидает, что об ошибке ему сообщат сразу, а не через два часа. Думаю, многим в процессе отладки нравится подход Python и Java к выходу за границы массива (индекс вышел за границу --- сразу выдаётся ошибка) и не нравится подход C и C++ (индекс вышел за границу --- программа может сделать что угодно и когда угодно; например, войти в бесконечный цикл в совершенно другой функции).
  2. Если ошибка проверяется в самый первый момент, вы её найдёте на всех тестах, где она вообще возникает. Если вы смотрите только на последствия, то ошибка может проявляться в меньшем количестве случаев, будет сложнее обнаружить.
  3. Если нарушается какой-то инвариант, то лучше, чтобы программа как можно раньше прекратила творить чушь и сообщила об ошибке программисту.

Мифы:

  1. Если программа не упадёт сразу, она может отработать корректно. Действительно, может. А может и не отработать корректно и вы об этом не узнаете, пока не наступят весьма неприятные последствия. По сути, когда вы "заглушаете" ошибку, вы надеетесь на три вещи одновременно: ваша программа вообще может выдать что-то осмысленное при этой ошибке (может, вообще нет никаких корректных действий и ей надо остановиться), ваш код после ошибки может это осмысленное сделать при некоторых входных данных, вы _угадали_, что нужно передать этому коду (то самое значение, которым вы заглушаете ошибку).
  2. Если программа раньше падала, а теперь не падет --- стало на одну ошибку меньше. Неверно, могло стать на одну ошибку больше. Когда вы запускаете программу --- вы тестируете её на каком-то небольшом наборе тестов. Если вам повезло и ваше заглушение ошибки убрало падения на этом наборе, это вовсе не означает, что программа корректно работает на остальных. Более того, это также не означает, что она никогда не будет падать. Например, вы можете поменять какой-то другой кусок программы, переписать его на "эквивалентный", а потом обнаружить, что эквивалентность была только про нормальной работе, но не при заглушенной ошибке.

Осторожно:

  1. Стоит отличать "заглушение" ошибки и корректную обработку ошибки. Например, если в спецификации описано, что при делении любого положительного числа на ноль должно получаться специальное значение +inf (как в вещественных числах) --- этот случай действительно можно обработать отдельно.
  2. Несмотря на то, что в спецификации может быть написано, что дозволяется абсолютно любое поведение (привет из стандарта C++), стоит делать поведение неочевидным только если это как-то упрощает или улучшает код. Если же дополнительная проверка усложняет и код, и обнаружение ошибки в момент её возникновения --- уберите проверку.

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. Ещё одно следствие генераторов: код вроде [load_file(f) for f in files] вызовет <code>load_file прямо в момент вызова этой строчки. А вот map создаст генератор и будет читать файлы лениво --- только когда запросят очередной элемент списка. Это может не соответствовать желаемому поведению программы (например, хочется сначала всё прочитать в память, а потом с этим работать).
  5. Если вы присваиваете в срез уже существующего списка или матрицы, то не требуется превращать результат map в отдельно живущий список. Rule of thumb: если из результата работы map будет произведено копирование, причём ровно одно, его в список можно не превращать.
  6. 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 (а где она лежит --- сама разберётся).
  3. По shebang всякие IDE могут определять версию питона, под которой надо запускать (вторая или третья).

Мифы:

  1. Я сижу в IDE/на винде/на маке и всё делаю мышкой, не через консоль. Вы --- может быть да, но есть и другие люди. Например, проверяющий преподаватель или ваши будущие коллеги, которые могут не использовать IDE, а работать в Vim/Emacs и из консоли. Хорошо бы, чтобы у них код тоже работал без каких-либо изменений с их стороны.

Осторожно:

  1. Shebang предназначен только для скриптов, которые можно запускать. Не стоит добавлять его в код какой-нибудь питоновской библиотеки. Rule of thumb: если у команды python your_code.py есть адекватный результат работы, то Shebang в your_code.py нужен. Иначе --- не нужен.

Заворачивайте код программы в if __name__ == "__main__" и функцию main

Don't:

#!/usr/bin/env python3
def foo():
    print("foo")
foo()
print("Hello World")

Do:

#!/usr/bin/env python3
def foo():
    print("foo")

def main():
    foo()
    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()

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


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

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

Называйте неиспользуемую переменную цикла _

Dont't:

a = []
for i in range(10)
    a.append(input())
b = [10 for i in a]  # len(b) == len(a)

Do:

a = []
for _ in range(10)
    a.append(input())
b = [10 for _ in a]  # len(b) == len(a)

Причины:

  1. Если у переменной есть имя, при чтении кода возникает желание понять, как она используется. В Python есть стандартная конвенция: если переменная называется _, то она нигде не используется. Это помогает читать код проще: видите цикл с переменной _ --- значит, он нужен лишь для нескольких повторов, а вообще итерации одинаковы.

Мифы:

  1. Если будет два вложенных цикла с одинаковой переменной, всё сломается. В Python это неверно: сами по себе циклы будут работать совершенно корректно, но значение такой переменной действительно будет вести себя странно. Поскольку она не используется, на это пофиг.

Осторожно:

  1. В некоторых других языках _ не несёт такого же смысла, как в Python и может выглядеть инородно.
  2. В некоторых других языках семантика цикла отличается, и если переменную переиспользовать, то цикл будет работать неверно. Например, в C++.


Haskell

Используйте pattern matching вместо вспомогательных функций

Don't:

duplicateHead :: [a] -> [a]
duplicateHead l = (head l):(head l):(tail l)

duplicateFst :: (a, b) -> (a, a)
duplicateFst p = (fst p, fst p)

data Heap = Node Int Heap Heap | Nil
getValue (Node v _ _) = v
getLeft  (Node _ l _) = l
getRight (Node _ _ r) = r

sumHeap :: Heap -> Int
sumHeap Nil = 0
sumHeap n   = (getValue n) + (sumHeap $ getLeft n) + (sumHeap $ getRight n)


Do:

duplicateHead :: [a] -> [a]
duplicateHead (x:xs) = x:x:xs

duplicateFst :: (a, b) -> (a, a)
duplicateFst (a, _) = (a, a)

data Heap = Node Int Heap Heap | Nil
sumHeap :: Heap -> Int
sumHeap Nil = 0
sumHeap (Node v l r) = v + (sumHeap l) + (sumHeap r)

Причины:

  1. Pattern matching — это встроенная в язык конструкция, которая гарантированно работает правильно. Вспомогательные функции для получения элементов обычно пишутся самостоятельно и, как следствие, их можно неудачно назвать или даже допустить в них баг.
  2. При использовании pattern matching компилятор может проверить, что вы действительно разобрали все случаи. Если вы используете вспомогательные функции, компилятору остаётся лишь надеяться на то, что вы позаботились о том, чтобы вызывать их с корректными параметрами. Лучше ошибка компиляции, чем ошибка во время выполнения.
  3. Код получается сильно короче и яснее: l вместо getLeft n в коротких функциях читается намного лучше (а длинных функций мы не пишем).

Используйте pattern matching с let и where вместо вспомогательных функций

Don't:

foo l = (head $ reverse l) + (head $ tail $ reverse l)

Do:

foo l = let (x:y:_) = reverse l in x + y

Do:

foo l = x + y
  where
    (x:y:_) = reverse l

Причины:

  1. Pattern matching более точно выражает намерения, код получается короче и сложнее ошибиться.

Осторожно:

  1. let --- это выражение, которое что-то возвращает (конкретно --- свою правую часть после in), его можно использовать где угодно. А where --- это блок кода после функции, живущий отдельно от выражения.
  2. В where, в отличие от let, можно писать многострочные определения функций и даже несколько функций.

Вместо имён неиспользуемых переменных пишите _

Don't:

foo a b []     = 0
foo a b (x:xs) = a + b + x

Do:

foo _ _ []    = 0
foo a b (x:_) = a + b + x

Причины:

  1. Вы явно обозначаете, от каких параметров зависит функция в конкретном случае, а от каких не зависит.
  2. У читателя не возникает ощущения, что он(а) не понимает, где используется аргумент/переменная.

Мифы:

  1. _ можно использовать только один раз. На самом деле в Haskell это специальный символ (в отличие от Python, где это обычное имя) и обрабатывается отдельно от имён переменных. Вы в принципе не можете обратиться к переменной, вместо которой написали _.
  2. _ можно использовать только вместо параметра целиком. Неверно, также можно использовать внутри pattern matching в каких-то кусках.

Осторожно:

  1. Шаблон (_:_) не эквивалентен _. Первый шаблон соответствует только непустым спискам, а второй --- вообще всем.
  2. Шаблон [] не эквивалентен _. Первый соответствует только пустым спискам, второй --- вообще всем.

Используйте одинаковое имя для переменной во всех определениях функции

Don't:

foldr _ a []    = a
foldr a b (c:d) = a c (foldr a b d)

Do:

foldr _ z []     = z
foldr f z (x:xs) = f x (foldr z xs)

Причины:

  1. Это соответствует обычным императивным языкам: там у нас у функции не бывает "случаев" и параметры всегда называются одинаково. Проще читать код и соответствует ожиданиям.
  2. Обычно самое интересное про функцию находится справа от =, то есть читателю хочется понять, чем отличаются разные случаи и по какому поводу они вообще разные. Если у вас переменные называются по-разному в разных случаях, то нельзя так просто взять и сравнить две строчки.

Вместо случая "все параметры какие угодно" пишите явно, что вы ожидаете

Don't:

map' f (x:xs) = (f x):(map' f xs)
map' _ _      = []

replicate' n a = a:(replicate' (n - 1) a)
replicate' _ _ = []

-- Вкусовщина, но мне так не нравится
zipPairs (x:y:xs) = (x, y):(zipPairs xs)
zipPairs _        = []

Do:

map' f (x:xs) = (f x):(map' f xs)
map' _ []     = []

replicate' n a = a:(replicate' (n - 1) a)
replicate' 0 _ = []

-- Вкусовщина, но мне так нравится больше
zipPairs (x:y:xs) = (x, y):(zipPairs xs)
zipPairs [x]      = []
zipPairs []       = []

Причины:

  1. Становится неважен порядок определения: если у вас случаи [] и (x:xs), то они могут идти в любом порядке. А если у вас (x:xs) и _, то они должны идти ровно в таком порядке.
  2. Становится явно видно, какие случаи и на каких параметрах вы разбираете: пустой и непустой список; нулевое n и ненулевое. Так и читать проще, и ловить ошибки проще. Например, для replicate' сразу видно, что при отрицательном n будет выбран первый случай.
  3. Компилятор может проверить, что разобраны действительно все случаи и вы ничего не забыли. Если есть случай _ _ _, то он обработает вообще все случаи.

Осторожно:

  1. В случае с guards может быть полезно писать otherwise, потому что Haskell обычно не может доказать, что случаи исчерпываются перечисленным. Например, он не знает, что для любых двух переменных либо a < b, либо a >= b.

Лучше прописывать типы сложных функций явно

Don't:

foldr' _ z []     = z
foldr' f z (x:xs) = f x (foldr' f z xs)

Do:

foldr' :: (a -> b -> b) -> b -> [a] -> b
foldr' _ z []     = z
foldr' f z (x:xs) = f x (foldr' f z xs)

Причины:

  1. Иногда можно так опечататься, что тип функции Haskell всё равно выведет, но он будет совсем неверный. Например, вместо f x (foldr' f z xs) написать f (foldr' f z xs) или x (foldr' f z xs) --- это всё компилируется. Как следствие, вы обнаружите это только при попытке использования функции, и тогда ошибка несоответствия типов будет занимать от половины до десятка экранов, понять будет гораздо сложнее. Более того --- нет никаких гарантий, что это ошибка возникнет при первом использовании функции.
  2. При чтении кода иметь сигнатуру перед глазами довольно приятно: обычно это даёт сильную подсказку о том, что делает или может делать функция.

Осторожно:

  1. Не все согласны, что типы надо прописывать явно, руководствуйтесь style guide или чьим-нибудь чувством прекрасного.
  2. В некоторых случаях типы настолько очевидны, что писать лишнюю строчку незачем.
  3. В некоторых случаях типы получаются настолько сложными, что лучше доверить их вывод компилятору. Впрочем, в таком случае я считал, что функция получилась слишком сложной и её стоит разделить на несколько.
  4. Есть мнение, что лучше называть типовые переменные (a, b в примерах выше) так, чтобы они не совпадали по названиям с переменными внутри функции (f, z, x, xs в примерах выше) --- тогда они не путаются.
  5. Максимально обобщайте типы, если пишете самостоятельно: не sort :: [Int] -> [Int], а sort :: Ord a => [a] -> [a].