Списки, кортежи и keyword-списки

Списки, кортежи и keyword-списки выглядят похоже, но устроены и применяются по-разному. Выбор правильной структуры — половина производительности.

Список — для переменной длины и обхода; кортеж — для фиксированной группы; keyword list — для опций. Перепутаешь — получишь медленный код.

Список — связная цепочка ячеек. Дёшево добавлять в голову и обходить, дорого — лезть по индексу или в конец:

list = [1, 2, 3]
[0 | list]            # [0, 1, 2, 3] — O(1), добавили в голову
list ++ [4]           # [1, 2, 3, 4] — O(n), проход до конца
length(list)          # 3 — тоже O(n)

Кортеж хранится непрерывно — быстрый доступ по индексу, но дорогое «изменение» (копирование всего):

point = {3, 4}
elem(point, 0)        # => 3, O(1)
put_elem(point, 0, 9) # => {9, 4} — копирует кортеж

Keyword list — список пар {ключ-атом, значение} с сахаром, классика для опций функций:

opts = [sort: true, limit: 10]
Keyword.get(opts, :limit)   # => 10

Как работает под капотом (BEAM)

Список — это односвязная структура: каждая ячейка хранит значение (head) и ссылку на остаток (tail). Поэтому добавление в голову через [x | list] бесплатно — старый список переиспользуется как хвост, а доступ к n-му элементу требует пройти n ссылок. Кортеж же лежит в памяти подряд, как массив фиксированной длины, отсюда быстрый elem/2, но «изменение» означает копирование всего кортежа. Keyword list под капотом — обычный список кортежей-пар, поэтому он сохраняет порядок и допускает повторяющиеся ключи.

  Список:  [1 | [2 | [3 | []]]]
            head  head  head  конец
            добавить в голову = O(1)

  Кортеж:  | 3 | 4 |   (подряд в памяти)
            доступ по индексу = O(1)

Та же идея на Python ▶

В Python list ближе к динамическому массиву, tuple — к кортежу Elixir, dict — к keyword-опциям.

# Список: добавление и обход
nums = [1, 2, 3]
nums = [0] + nums            # как [0 | list]
print(nums)                  # [0, 1, 2, 3]

# Кортеж: фиксированная группа, доступ по индексу
point = (3, 4)
print(point[0])              # 3
point = (9,) + point[1:]     # "изменение" = новый кортеж
print(point)                 # (9, 4)

# Keyword-опции через словарь
opts = {"sort": True, "limit": 10}
print(opts.get("limit"))     # 10

Частые ошибки

  • Добавлять в конец списка в цикле. list ++ [x] — O(n); в горячем цикле это квадратичная катастрофа. Копите в голову и в конце разверните.
  • Брать length в условии цикла. Это O(n) каждый раз; матчите на [] вместо проверки длины.
  • Хранить динамические данные в кортеже. Кортеж — для фиксированной структуры, а не для коллекции переменного размера.

Best practices

  • Накапливайте элементы добавлением в голову, затем один раз Enum.reverse — это O(n) вместо O(n²).
  • Используйте кортежи для возврата нескольких значений и фиксированных записей вроде {:ok, data}.
  • Передавайте опции функций keyword-списком — это идиома стандартной библиотеки.

Итог. Три похожие структуры решают три разные задачи. Понимание их стоимости отделяет наивный код от быстрого. Дальше — модуль Enum, который красиво обходит коллекции, и его ленивый собрат Stream.

Практическое правило выбора

Сведём выбор структуры к простому правилу. Нужна последовательность, которую вы перебираете и наращиваете с головы — список. Нужна фиксированная группа разнородных значений, к которым обращаетесь по позиции (как запись с полями), — кортеж. Нужно передать набор опций функции — keyword list. Нужен словарь с произвольными ключами и быстрым доступом — map. Перепутав, вы не получите ошибку компиляции, но можете получить неожиданно медленный код.

Классический антипаттерн новичка — строить список добавлением в конец внутри цикла. Поскольку это O(n) на каждом шаге, обработка тысячи элементов превращается в миллион операций. Правильная идиома: накапливать добавлением в голову (O(1)) и один раз развернуть результат через Enum.reverse в конце, либо вовсе использовать Enum.map/reduce, которые делают это эффективно за вас. Понимание стоимости операций здесь — не педантизм, а разница между сервисом, который держит нагрузку, и тем, который ложится под ней.

Проверьте себя
1. Почему list ++ [x] (добавление в конец) — дорогая операция?
AЭто не дорого
BСписок односвязный, и нужно пройти его целиком, чтобы добраться до конца — O(n)
CЭто синтаксическая ошибка
DКортежи быстрее только в Erlang
2. Для чего идиоматично использовать keyword list?
AДля больших коллекций
BДля передачи опций функции, например [sort: true, limit: 10]
CДля возврата ошибок
DДля хранения состояния процесса