Про инициализацию vs очищение, или мойте тарелки перед едой (а не после)

2.02.2018

Пусть у вас есть простая задача — дан двумерный массив, надо посчитать и вывести на экран сумму каждой строки этого массива. Как вы будете это писать? Я бы это написал так:

# a — данный массив
for i in range(len(a)):
    s = 0
    for j in range(len(a[i])):
        s += a[i][j]
    print(s)

Но очень часто в решения подобных задач я вижу код, аналогичный следующему:

# a — данный массив
s = 0
for i in range(len(a)):
    for j in range(len(a[i])):
        s += a[i][j]
    print(s)
    s = 0

Разница в том, что в первом случае я зануляю переменную s перед подсчетом суммы, а во втором — после.

На мой взгляд, это довольно существенная разница, и первый вариант лучше. И дело даже не в том, что во втором варианте присваивание s = 0 повторяется. Важнее то, что в первом варианте жизнь переменной s ограничена только одной итерацией цикла, во втором же варианте вы существенно используете тот факт, что при входе на очередную итерацию цикла у вас в s лежит ноль.

Даже просто понимать вторую программу чуть сложнее: тот факт, что значение s «переносится» на следующую итерацию цикла, надо заметить и осознать. Если вы проверяете, правильно ли работает программа, то во втором варианте вам надо контролировать значение переменной s на всем протяжении цикла, и при переходе на новую итерацию тоже. В первой же программе вам совершенно неважно, что происходит с s после команды print до начала новой итерации цикла.

Если вы будете дорабатывать, исправлять или улучшать вторую программу, то очень легко допустить ошибку следующего вида. Пусть нам не надо выводить сумму, если она получилась отрицательной. Легко:

# a — данный массив
s = 0
for i in range(len(a)):
    for j in range(len(a[i])):
        s += a[i][j]
    if s < 0:
        continue
    print(s)
    s = 0

…но в итоге мы пропустили и зануление переменной.

Разница еще больше видна в языках типа c++, где область видимости переменной можно строго ограничить. Первый код на c++ будет выглядеть так:

for (int i = 0; i < a.size(); i++) {
    int s = 0;
    for (int j = 0; j < a[i].size(); j++) {
        s += a[i][j];
    }
    std::cout << s << std::endl;
}

А во втором варианте вам придется вынести объявление s из цикла, что неестественно и неправильно:

int s = 0;
for (int i = 0; i < a.size(); i++) {
    for (int j = 0; j < a[i].size(); j++) {
        s += a[i][j];
    }
    std::cout << s << std::endl;
    s = 0;
}

Еще аргумент в пользу первого варианта — то, что код легко выносится в функцию, например, так:

def calc_sum(arr)
    s = 0
    for j in range(len(arr)):
        s += arr[j]
    return s

# a — данный массив
for i in range(len(a)):
    s = calc_sum(a[i])
    print(s)

Поэтому вообще это кажется полезным принципом: вместо того, чтобы очищать значения переменных в программе после их использования, инициализируйте их перед использованием. (Это не значит, что во всех переменных значения всегда останутся навсегда, приведут к утечке памяти и т.д. — это на самом деле несколько другой вопрос, и его касаться я сейчас не буду.)

На самом деле есть еще очень близкое соображение — в разного рода циклах старайтесь, чтобы логически связанный код не разрывался между итерациями, а жил полностью в пределах одной итерации. Чем меньше у вас информации, которая переходит с одной итерации цикла на другую, тем лучше. (Конечно, если это цикл for, то как минимум собственно переменная цикла перейдет, ну или неявное знание «где мы сейчас в цикле», но чем меньше других переменных, которые переходят на новую итерацию, тем лучше.)

Еще один пример — алгоритм Дейкстры (если вы не знаете, что это, то не так страшно). Там на каждой итерации цикла вы сначала находите вершину, расстояние до которой минимально, а потом релаксируете все ребра, выходящие из нее. Естественная реализация примерно такая:

for ...
    v = # найти вершину с минимальным расстоянием до нее
    for e in edges[v]:
        relax(e)

Но я один раз встречал инвертированную реализацию:

v = start_vertex
for ...
    for e in edges[v]:
        relax(e)
    v = # найти вершину с минимальным расстоянием до нее

(Ведь и правда, зачем искать вершину на первой итерации, мы ведь и так точно знаем, что это будет начальная вершина?)

Но это создает те же проблемы, что были описаны выше — значение переменной v переносится на новую итерацию, вам надо за этим следить и контролировать это. Более того, это разрывает логическую операцию: поиск вершины непосредственно связан с релаксацией всех ребер, выходящих из нее, а вот эта релаксация уже не особенно связана с поиском следующей вершины.


Мой курс по алгоритмическому программированию (и подготовке к олимпиадам) для школьников, студентов и всех желающих — algoprog.ru