Все статьи по Python
23 мар. 2025 г. - 28 мин. чтения
Списки и циклы

Списки и циклы

Обход списков. Циклы с индексами для работы со списками. Обработка списков в функциях. Агрегация. Удаление элементов списка

@ashtana

Штана Альберт Игоревич

Списки и циклы

Цикл for и списки

Обход коллекции настолько распространенная задача, что многие языки программирования решают это введением специального вида цикла. В Python можно воспользоваться циклом for ... in для обхода списка.

user_names = ['petya', 'vasya', 'evgeny']
# name на каждой итерации свой собственный (локальный)
for name in user_names:
    print(name)
# => "petya"
# => "vasya"
# => "evgeny"

Цикл знает, как перебирать элементы, и знает о том, когда они закончатся. В цикле создается переменная name. На каждой итерации она принимает следующее значение элемента списка user_names. Этот цикл отлично подходит для задач агрегации:

def calc_sum(coll):
    sum = 0
    for value in coll:
        sum += value
    return sum

print(calc_sum([3, 2, -5, 38, 0]))  # => 38

for ... in — это больше, чем просто цикл для списков. Он используется для обхода всех коллекций в Python. Так ранее мы проходили уже пример коллекции в виде кортежей. Обходить их тоже можно с помощью for ... in

Для полного понимания принципов его работы нужно разбираться в темах: объекты, итераторы, магические методы. Если по-простому, то разные данные в Python могут притворяться коллекциями элементов. Самый простой пример — это строка: for...in перебирает строку посимвольно.

greeting = 'Hello'
for symbol in greeting:
    print(symbol)
# => "H"
# => "e"
# => "l"
# => "l"
# => "o"

Циклы с индексами

Цикл for можно использовать не только для обхода списка, но и для повторения действий. Для этого используют функцию range(). Функция range() в Python используется для генерации последовательностей чисел. Она часто применяется в циклах for для задания диапазона чисел, которые нужно перебрать.

for i in range(1, 6):
  print(i)
# => 1
# => 2
# => 3
# => 4
# => 5

Функция range в Python является встроенной функцией, которая создает последовательность чисел внутри определенного диапазона. Ее можно использовать в цикле for для контроля количества итераций.

У range() есть несколько вариантов использования:

  • range(stop) создает последовательность от 0 до stop - 1
  • range(start, stop) создает последовательность от start до stop - 1
  • range(start, stop, step) создает последовательность из чисел от start до stop - 1, с шагом step.

Теперь попробуем вывести числа в обратном порядке:

for i in range(3, 0, -1):
    print(i)

# => 3
# => 2
# => 1

На примерах выше мы видим, что итерация завершается до конечного значения.

Обход списка с использованием индекса

Можно использовать range() для получения индексов элементов списка, что может быть полезно, если нужно работать с индексами:

user_names = ['petya', 'vasya', 'evgeny']
end = len(user_names)

# range(begin, end) создает последовательность чисел от begin до end
# значение end не включается в последовательность
for i in range(0, end):
  print(user_names[i])

# => petya
# => vasya
# => evgeny

В данном коде создаем список из трех элементов — имен. Далее в цикле обходим список и выводим на экран все имена так, что каждое имя оказывается на новой строке (print() автоматически делает перевод строки). Рассмотрим этот этап подробнее. При обходе списка циклом for счетчик, как правило, играет роль индекса в списке. Он инициализируется нулем и увеличивается до len(user_names) - 1, что соответствует индексу последнего элемента. А что, если нам нужно вывести значения в обратном порядке? Для этого есть два способа. Один — идти в прямом порядке, то есть от нулевого индекса до последнего, и каждый раз вычислять нужный индекс по такой формуле: размер списка - 1 - текущее значение счетчика.

user_names = ['petya', 'vasya', 'evgeny']
end = len(user_names)
for i in range(0, end):
  index = (len(user_names) - 1) - i
  print(user_names[index])
# => evgeny
# => vasya
# => petya

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

user_names = ['petya', 'vasya', 'evgeny']
end = len(user_names)
# Здесь в range передается третий параметр - шаг
# и так же как со срезами, если передается отрицательный шаг, то обход будет в обратную сторону
for i in range(end - 1, -1, -1):
  print(user_names[i])
# => evgeny
# => vasya
# => petya

Код выше можно сделать проще с помощью функции reversed()

user_names = ['petya', 'vasya', 'evgeny']
end = len(user_names)
for i in reversed(range(end)):
  print(user_names[i])
# => evgeny
# => vasya
# => petya

Изменение списка во время обхода

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

emails = ['VASYA@gmAil.com', 'iGoR@yandex.RU', 'netiD@hot.CoM']
print(emails)
# => ['VASYA@gmAil.com', 'iGoR@yandex.RU', 'netiD@hot.CoM']
for i in range(0, len(emails)):
  # lower() - стандартный метод строк Python
  # преобразующий строку в нижний регистр
  email = emails[i]
  normalized_email = email.lower()
  # Заменяем значение
  emails[i] = normalized_email
print(emails)
# => ['vasya@gmail.com', 'igor@yandex.ru', 'netid@hot.com']

Ключевая строчка: emails[i] = normalized_email. В ней происходит перезапись элемента под индексом i.

Цикл while со списком

Цикл for можно заменить на while. Тогда нам необходимо будет вручную изменять счетчик:

user_names = ['petya', 'vasya', 'evgeny']
i = 0
# Определяем цикл. В переменной name на каждом шаге цикла будет храниться следующий элемент списка
# Условие выполняется пока i < 3
while i < len(user_names):
  name = user_names[i]
  print(name)
  # увеличиваем счетчик
  i += 1
# => petya
# => vasya
# => evgeny

Цикл while можно использовать для работы со списками, но он имеет несколько потенциальных минусов по сравнению с циклом for, когда речь идёт о переборе элементов списка. В цикле while нужно явно контролировать условие завершения. Если вы забыли обновить счётчик или неправильно задали условие выхода, цикл может стать бесконечным. Например:

numbers = [10, 20, 30, 40]
i = 0
# Этот цикл будет бесконечным
while i < len(numbers):
  print(numbers[i])

Или если укажете неверное условие, то выйдите за пределы списка:

numbers = [10, 20, 30, 40]
i = 0
# Этот цикл выйдет за границы списка
while i <= len(numbers):
  print(numbers[i])
  i += 1
# => 10
# => 20
# => 30
# => 40
# IndexError: list index out of range

Ссылочные данные

В Python в отличие от других языков, нет разделения на примитивные типы и ссылочные. Все всегда передается по ссылке. С точки зрения прикладного программиста, разница проявляется при изменении данных, их передаче и возврате из функций. Нужно держать в уме, что списки как коллекции хранят в себе не сами значения, а ссылки на них. При этом списки сами тоже передаются по ссылкам. Чтобы убедиться в этом, создадим несколько переменных, содержащих один список, и посмотрим, как они меняются:

items = [1, 2]
items2 = items
items2[0] = "python"
print(items2) #=> ["python", 2]
print(items) #=> ["python", 2]

В примере выше создаем новую переменную items2 и записываем в нее ссылку на переменную items. Теперь две переменные ссылаются на один и тот же список. А значит изменив список в любой из переменных, он поменяется и для другой.

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

a = 42
id(a) # 140708400662728

Идентификатор — это обычное число. Но у каждого объекта свой уникальный идентификатор. Поэтому идентификаторы удобно использовать, чтобы отслеживать передачи ссылок на объект между разными участками кода — идентификатор объекта будет одним и тем же, по какой бы ссылке к объекту ни обращались:

a = "Hello, Python!"
b = a
id(a)  # 1871043874544
id(b)  # 1871043874544
print(a is b)  # => True

Когда мы создаем переменную и записываем в нее значение, то мы даем имя ссылке. Далее, мы присваиваем одну переменную другой, и даем еще одно, новое имя для этой же ссылки. Поэтому id(a) и id(b) возвращают одинаковый результат. Оператор is проверяет равенство идентификаторов своих операндов. В этом примере обе переменные ссылаются на один объект, поэтому проверка a is b дает True. Проверкой is в Python пользуются, когда мы имеем дело с так называемыми объектами-одиночками. Самые известные одиночки в Python, это True, False и None. Поэтому проверка на равенство None обычно пишется так: if foo is None:

Сравнение списков

Оператор == сравнивает списки, и любые другие объекты, по значению. То есть два списка будут равны, если имеют одинаковые значения:

[1, 2, 3] == [1, 2, 3] # True

Списки также можно сравнивать и по ссылке:

items = [1, 2, 3]
items2 = [1, 2, 3]
print(items2 == items) # True
print(items2 is items) # False

В этом примере, хоть списки и содержат одинаковые значения, но каждый список ссылается на свой адрес в виртуальной памяти.

Проектирование функций работающих со списками

Если передать список в какую-то функцию, которая его изменяет, то список тоже изменится. Ведь в функцию передается именно ссылка на список. Посмотрите на пример:

def append_wow(some_list):
  some_list.append('wow')

items = ['one']
append_wow(items)
print(items) # => ['one', 'wow']
append_wow(items)
print(items) # => ['one', 'wow', 'wow']

Проектируя функции, работающие со списками, есть два пути: менять исходный список или формировать внутри новый и возвращать его наружу. Какой лучше? В подавляющем большинстве случаев стоит предпочитать второй. Это безопасно. Функции, возвращающие новые значения, удобнее в работе, а поведение программы становится в целом более предсказуемым, так как отсутствуют неконтролируемые изменения данных. Изменение списка может повлечь за собой неожиданные эффекты. Представьте себе функцию last(), которая извлекает последний элемент из списка. Она могла бы быть написана так:

def last(items):
  # Метод .pop() извлекает последний элемент из списка
  # Он изменяет список, удаляя оттуда этот элемент
  return items.pop()

items = [1, 2, 3]
last_item = last(items)
print(last_item) # 3
print(items) # [1, 2]

Где-то в коде вы просто хотели посмотреть последний элемент. А в дополнение к этому, функция для извлечения этого элемента взяла и удалила его оттуда. Это поведение очень неожиданно для подобной функции. Оно противоречит большому количеству принципов построения хорошего кода (например "Command–query separation"). Правильная реализация данной функции выглядит так:

# Список не изменяется
# Индекс -1 означает первый элемент с конца
def last(items):
  return items[-1]

items = [1, 2, 3]
print(last(items)) # => 3
print(items) # => [1, 2, 3]

В каких же случаях стоит менять сам список? Есть ровно одна причина, по которой так делают – производительность. Именно поэтому некоторые встроенные методы списков меняют их, например reverse() или sort():

items = [3, 2, 1, 5, 4]
items.sort()
print(items) # => [1, 2, 3, 4, 5]
items.reverse()
print(items) # => [5, 4, 3, 2, 1]

Копирование списков

В Python нет встроенных методов или функций, которые изменяют список, но возвращают новый, не трогая старый. Чтобы изменить список, не затрагивая изначальный, его нужно скопировать.

def append(items, item):
  # Или items_copy = items[:]
  items_copy = items.copy()
  items_copy.append(item)
  return items_copy

items = [1, 2, 3]
items2 = append(items, 4)
print(items) # => [1, 2, 3]
print(items2) # => [1, 2, 3, 4]

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

Агрегация

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

def calc_max(lst):
  # Если коллекция пустая, то у нее не может быть максимального значения
  # В подобных ситуациях принято возвращать None
  # Это классический пример использования идиомы guard expression
  if lst == []:
    return None
  # Сравнение элементов начинаем с первого элемента
  max = lst[0]
  # Обход начинаем со второго элемента
  for i in range(1, len(lst)):
    current = lst[i]
    # Если текущий элемент больше максимального,
    # то он становится максимальным
    if current > max:
      max = current
  # Не забываем вернуть максимальное число
  return max

print(calc_max([])) # => None
print(calc_max([3, 2, -10, 35, 0])) #=> 35

В этом примере агрегации мы видим вычисление. Оно включает в себя сравнение всех элементов для поиска одного, которое станет результатом этой операции. Обратите внимание, что начальным значением max взят первый элемент, а не 0 или любое другое число. Ведь может оказаться так, что все числа в списке меньше 0, и тогда мы получим неверный ответ.

Нейтральный элемент

Рассмотрим поиск суммы:

def calc_sum(lst):
    # Начальное значение суммы
    sum = 0
    for i in range(len(lst)):
        # Поочередно складываем все элементы
        sum += lst[i]
    return sum

# Сумма элементов всегда возвращает какое-то число
# Если список пустой, то сумма его элементов равна нулю
print(calc_sum([]))  # => 0
print(calc_sum([3, 2, -10, 38, 0]))  # => 33

Процесс вычислений: sum = 0 => sum = sum + 3 # 3 => sum = sum + 2 # 5 => sum = sum + -10 # -5 => sum = sum + 38 # 33 => sum = sum + 0 # 33

Алгоритм поиска суммы значительно проще, но обладает парой важных нюансов. Чему равна сумма элементов пустого списка? С точки зрения математики такая сумма равна 0, что совпадает со здравым смыслом. Если у нас нет яблок, значит у нас есть 0 яблок. Другими словами, количество яблок равно нулю. Функции в программировании работают по такой же логике. Второй момент связан с начальным элементом суммы. У переменной sum есть начальное значение равное 0. Зачем вообще задавать значение? Любая повторяющаяся операция начинается с какого-то значения. Нельзя просто так объявить переменную и начать с ней работать внутри цикла. Это приведет к неверному результату:

# В Python мы не можем создать переменную не задав какое-то значение
# В качестве "отсутствия значения" используем None
sum = None
# Первая итерация цикла
sum = sum + 1

В результате такого вызова внутри будет ошибка TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'. Она возникает из-за попытки сложить 1 и None. Значит какое-то значение все же нужно. В коде выше выбран 0, потому что все остальные варианты приведут к неверному результату. Если начальное значение будет равно 1, то результат получится на единицу больше, чем нужно. В математике существует понятие нейтральный элемент бинарной операции — это элемент, который ничего не меняет в результате его использования. Другими словами, сложение любого числа с нулем всегда дает это же число. Тогда любую сумму, например 3 + 2 + 8, можно вычислить как 0 + 3 + 2 + 8, чем мы и пользуемся в нашем коде. Нейтральный элемент – это важная часть любой агрегирующей операции. Именно с него начинается сам процесс агрегации. В случае сложения – это 0, в случае умножения – 1. Даже у конкатенации есть нейтральный элемент – это пустая строка: '' + 'one' будет 'one'. Агрегация далеко не всегда означает, что коллекция элементов сводится к некоторому простому значению. Результатом агрегации может быть даже сложная структура — например, список. Подобные примеры часто встречаются в реальных задачах. Самый простой пример – это список уникальных слов в тексте.

Удаление элементов списка

В Python для удаления элементов из списка есть метод .pop() и оператор del. Но они изменяют изначальный список, что может привести к ошибкам в коде:

# Если удалять элементы в цикле
# то ломается порядок обхода
numbers = [1, 0, 2, 0, 3, 4]
for i in range(len(numbers)):
    print('Текущий элемент = ', numbers[i])
    if numbers[i] == 0:
        numbers.pop(i)

#=> Текущий элемент =  1
#=> Текущий элемент =  0
# 0
#=> Текущий элемент =  0
# 0
#=> Текущий элемент =  4
# IndexError: list index out of range

В примере выше мы получили ошибку, потому что удаляя элемент мы смещаем указатель дальше по списку, и перепрыгиваем элементы. При этом задача удаления возникает регулярно. Причем обычно удаляется не один элемент, а набор элементов по определенным правилам. Например, довольно распространена операция compact – удаление None значений из списка. Как правильно ее реализовать? В подавляющем большинстве ситуаций изменение списка должно трансформироваться в создание нового списка, в котором отсутствуют удаляемые элементы. Ниже пример реализации функции compact():

def compact(lst):
    # Инициализация результата
    # Для пустой входной коллекции результатом будет пустой список
    result = []
    for item in lst:
        if item is not None:
            result.append(item)
    return result

print(compact([0, 1, False, None, True, 'wow', None]))
# => [0, 1, False, True, 'wow']
print(compact([]))
# => []

Главное, на что здесь нужно обратить внимание - не изменяется исходный список coll. Вместо этого создается новый список result, который наполняется только подходящими под условие значениями.

Попробуйте сами запустить код в окне ниже с интерпретатором Python и повторите примеры из статьи чтобы самим увидеть и понять как всё это работает. Для этого в ячейке с кодом нажмите клавиши на клавиатуре Shift+Enter или запустите код через кнопку Run по значку ▶.