Python з нуля · Блок 2 · Тема 06

Список: полиця з ярликами

Найуживаніша структура даних Python і найщедріше джерело багів «я цього не чіпав». Обидві властивості випливають з одного факту: список зберігає не значення, а посилання — і його можна змінити на місці.

У темі 05 ти навчився працювати з рядком: брати символ за індексом, різати зрізом, склеювати. І запамʼятав головне обмеження — рядок незмінний. Будь-яка «зміна» насправді створює новий рядок. Це зручно й безпечно, поки тобі достатньо тексту. Але щойно треба тримати десяток різних значень і по черзі їх правити, потрібен інший інструмент. Він називається список (list), і саме з нього починається справжнє програмування.

Наскрізний приклад усієї теми — банальний список покупок. Він достатньо простий, щоб не відволікати, і достатньо реальний, щоб пастки в ньому були справжніми.

01 / СтворенняНавіщо потрібен список

Уяви, що треба зберегти три покупки. Без списку виходить так:

покупка_1 = "хліб" покупка_2 = "молоко" покупка_3 = "яблука"

Три імені для трьох речей. Додалась четверта — дописуй рядок коду. Скільки всього покупок? Відсортувати? Немає як: три окремі імені нічим між собою не повʼязані, і Python не знає, що це «одне й те саме, тільки різне».

Список розвʼязує це одним махом. Він упорядкований набір значень під одним іменем:

покупки = ["хліб", "молоко", "яблука"] >>> покупки ['хліб', 'молоко', 'яблука'] >>> len(покупки) 3

Квадратні дужки — літерал списку, кома розділяє елементи. Функція len() повертає кількість елементів; вона працює і для рядка (тема 05), і тут — це загальний спосіб спитати «скільки в тобі всього».

Створити список можна ще двома способами. Порожній: [] або list(). І перетворенням з іншої послідовності: list("абв") дасть ['а', 'б', 'в'] — рядок розсипається на окремі символи.

Важлива дрібниця, яка відрізняє Python від багатьох мов: елементи не зобовʼязані бути однакового типу. Список — це просто послідовність чого-завгодно:

товар = ["молоко", 32.50, True, ["1 л", "3.2%"]]

Рядок, дробове число, логічне значення й ще один список усередині — ніякої помилки. Зараз зрозуміємо, чому це взагалі можливо.

02 / ПамʼятьЩо насправді лежить у комірках

У темі 03 ми зʼясували: імʼя — це ярлик на обʼєкт, а не коробка зі значенням. Список влаштований так само, тільки ярликів у ньому багато й звуться вони не іменами, а комірками. Комірка з номером 0, комірка з номером 1 і так далі.

Кожна комірка не вміщує елемент — вона вказує на обʼєкт, який живе окремо в памʼяті. Саме тому список може тримати значення різних типів: усі комірки однакові за розміром, бо в кожній лежить не значення, а посилання.

Ця одна фраза пояснює половину теми. Пройди інтерактив крок за кроком і подивись, що відбувається, коли ми присвоюємо щось у комірку.

Інтерактив 1 · Список зберігає посилання

Угорі — сам список. Унизу — обʼєкти, на які вказують його комірки.

покупки
дублікат
len()
обʼєктів
Головне з картинки: на третьому кроці покупки[1] = "кефір" не змінило рядок «молоко» — рядки незмінні, змінити їх неможливо. Змінилась комірка: тепер вона дивиться на інший обʼєкт. А імʼя дублікат лишилось на старому й нічого не помітило.
Для тих, хто вже пише код. У C масив зберігає самі значення підряд: десять чисел int — рівно 40 байтів. Список Python зберігає підряд вказівники, по 8 байтів кожен, а самі числа розкидані по купі. Звідси і гнучкість, і ціна: список зі ста мільйонів чисел важить у рази більше за масив numpy тієї самої довжини, а обхід по ньому стрибає памʼяттю замість читати її послідовно.

03 / ДоступІндекс і зріз

Синтаксис тут точнісінько такий самий, як у рядка, — і це не збіг. Індексація та зрізи в Python описані для послідовностей взагалі, а рядок і список — два різні види послідовності. Вивчивши це один раз на рядках, ти отримав його безкоштовно для списків.

покупки = ["хліб", "молоко", "яблука", "мед"] >>> покупки[0] # перший елемент — нумерація з нуля 'хліб' >>> покупки[-1] # відʼємний індекс рахує з кінця 'мед' >>> покупки[1:3] # зріз: від 1 включно до 3 НЕ включно ['молоко', 'яблука'] >>> покупки[::2] # кожен другий ['хліб', 'яблука'] >>> покупки[::-1] # у зворотному порядку ['мед', 'яблука', 'молоко', 'хліб']

Одна принципова різниця з рядком усе ж є. "текст"[0] повертає рядок із одного символу — окремого типу «символ» у Python немає. А покупки[0] віддає сам обʼєкт із комірки: рядок, число, вкладений список — що там було, те й дістанеш.

Зріз завжди повертає новий список. Це найдешевший спосіб отримати копію — і ми до нього ще повернемось, бо копія ця не така надійна, як здається.

Вихід за межі — помилка, і це добре:

>>> покупки[9] IndexError: list index out of range

А от зріз за межі мовчить: покупки[2:99] просто віддасть усе, що є, без жодної скарги. Різницю варто памʼятати: індекс суворий, зріз поблажливий.

04 / ЗмінюваністьГоловна відмінність від рядка

Ось воно — те, заради чого списки взагалі існують. Спробуємо однакову дію з рядком і зі списком:

>>> покупки[0] = "булка" # список — спрацювало >>> назва[0] = "Б" # рядок TypeError: 'str' object does not support item assignment

Повідомлення читається буквально: обʼєкт типу str не підтримує присвоєння в елемент. Список підтримує. Списки — змінювані (mutable), рядки — незмінні (immutable), і це головний поділ, який ми ввели ще в темі 03.

Наслідок глибший, ніж здається. «Змінити» рядок означає створити новий обʼєкт — старий лишається цілим, і всі, хто на нього дивився, нічого не помітять. Змінити список означає перебудувати той самий обʼєкт — і це побачать усі, хто тримає на нього ярлик. Порівняй обидва сценарії поруч:

Інтерактив 2 · Змінюваний проти незмінного

Ліворуч список, праворуч рядок. Дія однакова, результат протилежний.

покупки
назва
id списку
id рядка
Дивись на номери обʼєктів: у списку id не змінився жодного разу — це весь час один обʼєкт із новим вмістом. У рядка інакше: щоб отримати «Хліб» замість «хліб», довелось зібрати новий рядок, і номер став інший.

05 / МетодиЩо список уміє робити із собою

Метод — це функція, яка живе всередині обʼєкта й працює саме з ним. Викликається через крапку: покупки.append("мед"). У рядка методи (upper(), split()) повертали новий рядок, бо іншого шляху не було. Методи списку переважно нічого не повертають — вони змінюють сам список.

Це джерело класичної помилки новачка:

>>> покупки = покупки.append("мед") # ← так НЕ треба >>> покупки None

Метод append дописав елемент і повернув None — «нічого». Це «нічого» ми й поклали в імʼя покупки, втративши список. Правило: методи, які змінюють список на місці, викликають окремим рядком, без присвоєння.

Перемикай варіанти в інтерактиві — і особливу увагу зверни на перші три: різниця між append і extend ловить кожного другого.

Інтерактив 3 · Шість методів наживо

Однаковий вихідний список, різні виклики. Помаранчевим — те, що змінилось.

len до3
len після
виклик повертає
Ключова пара: append додає один елемент — хай навіть цей елемент сам є списком, і тоді список стає вкладеним. extend додає кожен елемент переданої послідовності окремо. Довжина після append([...]) — 4, після extend([...]) — 5.

Повний набір, який варто знати напамʼять:

методщо робитьповертає
append(x)дописує один елемент у кінецьNone
extend(інша)дописує в кінець усі елементи іншої послідовностіNone
insert(i, x)вставляє x так, щоб він став елементом з номером iNone
remove(x)видаляє перший елемент, рівний x; немає такого — ValueErrorNone
pop(i)видаляє елемент за номером i (без номера — останній)сам елемент
clear()прибирає всі елементи, список лишається тим самим обʼєктомNone
index(x)номер першого входження x; немає — ValueErrorціле число
count(x)скільки разів x трапляється у спискуціле число
sort()сортує список на місціNone
reverse()перевертає порядок на місціNone
copy()поверхнева копія (див. розділ 08)новий список

index і count — єдині тут, що нічого не змінюють і повертають число. Але щоб просто спитати «чи є таке», є коротший оператор: "мед" in покупки дає True або False і не падає з помилкою, коли елемента немає.

Пастка з remove. Він шукає за значенням, а не за номером, і прибирає лише перше входження. Якщо в списку два «хліби», покупки.remove("хліб") прибере один — залишиться другий. А якщо хліба немає взагалі, буде ValueError, і програма зупиниться. Перед видаленням варто спитати if "хліб" in покупки — про умови буде тема 11.

06 / Зріз ліворучПрисвоєння зрізу

У реальному коді ця можливість трапляється нечасто, але чудово показує, наскільки глибоко зріз вбудований у мову. Його можна поставити ліворуч від знака «=» — і Python замінить цілу ділянку списку на іншу послідовність:

покупки = ["хліб", "молоко", "яблука", "мед"] >>> покупки[1:3] = ["сир", "кава", "сіль"] >>> покупки ['хліб', 'сир', 'кава', 'сіль', 'мед']

Ми вирізали дві комірки і вставили на їхнє місце три елементи: довжина зросла з чотирьох до пʼяти, бо при присвоєнні зрізу кількість елементів не мусить збігатися. Це інша операція, ніж покупки[1] = "сир", де змінюється рівно одна комірка.

Звідси два трюки: порожня послідовність праворуч видаляє ділянку (покупки[1:3] = []), а зріз нульової довжини вставляє, нічого не видаляючи (покупки[2:2] = ["мед"] — те саме, що insert(2, "мед")). І один справді корисний випадок:

покупки = ["сир"] # нове імʼя на новий обʼєкт, старий список цілий покупки[:] = ["сир"] # той самий обʼєкт, вміст замінено повністю

Перший рядок перечіпляє ярлик. Другий випорожнює й наповнює наявний обʼєкт — і якщо на цей список дивиться ще чиєсь імʼя, воно побачить оновлення разом з усіма.

07 / Порядокsort і sorted

Впорядкувати список можна двома способами, і плутанина між ними — друга за популярністю помилка після append:

Так само влаштована пара для перевертання: покупки.reverse() — на місці й None, зріз покупки[::-1] — новий список. Правило вибору: потрібен оригінал далі — бери функцію або зріз; не потрібен — бери метод, він нічого не копіює.

Обидва способи приймають key — правило, за яким порівнювати. Замість самих елементів Python порівнює те, що видає key. Наприклад, вбудована функція len як ключ упорядкує назви за довжиною:

>>> sorted(покупки, key=len) ['сир', 'мед', 'хліб', 'кава', 'сіль', 'яблука', 'молоко', 'гречка']

Придивись до результату уважно. Слова однакової довжини стоять не за абеткою — вони йдуть у тому самому порядку, у якому були у вихідному списку. Це не випадковість, а гарантія мови: сортування в Python стабільне (stable). Елементи, що рівні за ключем, ніколи не міняються місцями. Перевір це в інтерактиві — і заразом подивись, що робить із самим списком кожен із варіантів.

Інтерактив 4 · Сортування, ключ і стабільність

Ліворуч вихідний порядок, праворуч результат. Лінії показують, куди поїхав кожен елемент.

покупки після виклику
вираз повертає
Що шукати: у режимі key=len лінії всередині однієї групи довжини йдуть паралельно й жодного разу не перетинаються — це і є стабільність. У режимі sorted() ліва колонка лишається неторканою: функція повернула новий список, оригінал не постраждав.
Для профі: абетка проти кодів. Звичайний sort() порівнює рядки за номерами символів у Unicode, а не за українською абеткою. Літери і, ї, є, ґ в таблиці стоять окремим блоком після всіх інших кириличних — тому «сіль» опиниться після «сир», а «їжа» — узагалі в кінці. Для показу людині це неправильно: потрібне сортування за локаллю (locale.strxfrm як ключ) або хоча б key=str.lower, щоб великі літери не йшли попереду всіх малих. Алгоритм усередині — Timsort, гібрид сортування вставками й злиттям, придуманий Тімом Пітерсом саме для Python: на майже впорядкованих даних він працює за лінійний час, у найгіршому випадку — за O(n·log n), і він стабільний за побудовою.

08 / КопіїКопія, якої немає

У темі 03 ми вже ловили цю пастку — тепер розгорнемо її повністю, бо саме зі списками вона трапляється найчастіше.

>>> a = ["хліб", "молоко"] >>> b = a >>> b.append("мед") >>> a ['хліб', 'молоко', 'мед']

Ми чіпали b, а змінилось a. Причина відома: b = a не створює нічого — це другий ярлик на той самий обʼєкт. Щоб отримати справжній окремий список, копію треба замовити явно, і способів рівно чотири:

копія = a[:] # зріз «від початку до кінця» копія = list(a) # конструктор з наявної послідовності копія = a.copy() # метод, зʼявився в Python 3.3 — найзрозуміліший копія = copy.copy(a) # з модуля copy, працює з будь-яким типом

Усі чотири роблять одне й те саме — і всі чотири мають одне й те саме обмеження. Вони створюють поверхневу копію (shallow copy): новий список із новими комірками, але комірки заповнені тими самими посиланнями, що й в оригіналі. Обʼєкти, на які вони вказують, не копіюються.

Поки в списку лежать рядки й числа, різниці не видно: вони незмінні, зіпсувати їх усе одно неможливо. Але щойно всередині опиниться інший список — поверхнева копія протікає. Ось три способи скопіювати вкладений список і що з ними стається:

Інтерактив 5 · Де протікає поверхнева копія

Оригінал — список із двох списків. Обери спосіб копіювання й зроби дві зміни. У лістингу deepcopy — скорочено від copy.deepcopy.

оригінал
копія
Два кроки — два різні рівні. Крок 2 додає елемент у зовнішній список: тут зріз уже рятує, бо зовнішні комірки в нього власні. Крок 3 чіпає вкладений список — і зріз більше не рятує, бо на цей вкладений обʼєкт досі вказують обидві копії. Тільки deepcopy витримує обидва кроки.

Ліки — глибока копія (deep copy). Функція copy.deepcopy(a) обходить структуру рекурсивно і створює новий обʼєкт на кожному рівні вкладеності:

import copy оригінал = [["хліб", "молоко"], ["яблука"]] копія = copy.deepcopy(оригінал) копія[0].append("кава") >>> оригінал [['хліб', 'молоко'], ['яблука']] # цілий

Здавалося б, бери завжди deepcopy — і не думай. Ні: вона значно дорожча, бо обходить усе дерево обʼєктів і веде облік уже скопійованого, щоб не зациклитись. Правило таке: у списку лише незмінні елементи — вистачить a.copy(); є вкладені змінювані — потрібен deepcopy.

09 / ВкладеніСписки всередині списків

Список у комірці списку — звичайна річ. Найчастіше так представляють таблицю: зовнішній список — рядки, внутрішні — клітинки рядка.

чек = [ ["хліб", 2, 28.50], ["молоко", 1, 32.00], ["яблука", 3, 19.90], ] >>> чек[1] # весь другий рядок ['молоко', 1, 32.00] >>> чек[1][2] # третя клітинка другого рядка 32.0

Подвійні дужки читаються зліва направо: спершу чек[1] дає внутрішній список, потім [2] береться вже від нього.

А тепер найкоротший спосіб зіпсувати собі вечір. Оператор * повторює список — [0] * 3 дає [0, 0, 0], зручно. Спробуємо так само зробити таблицю 3×3:

>>> поле = [[0] * 3] * 3 >>> поле [[0, 0, 0], [0, 0, 0], [0, 0, 0]] # виглядає правильно >>> поле[0][0] = 9 >>> поле [[9, 0, 0], [9, 0, 0], [9, 0, 0]] # ⚠ змінились усі три рядки

Це та сама поверхнева копія, тільки в профіль. Внутрішній [0] * 3 обчислився один раз. Зовнішнє множення не копіювало його — воно поклало в три комірки три посилання на цей самий обʼєкт. Три рядки таблиці — це один рядок, показаний тричі.

Забігаючи наперед. Правильно таку таблицю будують списковим включенням: поле = [[0] * 3 for _ in range(3)]. Тут вираз [0] * 3 обчислюється заново на кожен обхід, тому й списки виходять різні. Конструкція for усередині дужок — це тема 13, а самі цикли — тема 12; зараз просто запамʼятай рецепт і причину, чому множення тут не працює.

10 / УсерединіДинамічний масив і ціна операцій

Цей розділ для тих, кому мало «працює — і добре». Список Python — це динамічний масив (dynamic array): суцільний шматок памʼяті з вказівниками підряд, який уміє рости. Суцільність дає головну перевагу — доступ за індексом коштує однаково, чи то нульовий елемент, чи мільйонний: адреса рахується арифметикою. Мовою складності це O(1), «стала кількість дій незалежно від довжини».

Але суцільність має й ціну. Щоб вставити елемент на початок, треба фізично зсунути всі інші на одну комірку вправо — місця посередині просто немає. Покрути повзунок і подивись, як росте ця робота:

Інтерактив 6 · Ціна append проти insert(0, x)

Помаранчевим позначено комірки, які доводиться фізично зсунути.

append: зсув
insert(0): зсув
зібрати n: append
зібрати n: insert(0)
Нижні два числа найважливіші. Щоб зібрати список із n елементів по одному в кінець, треба n дій. Щоб зібрати той самий список по одному на початок — n·(n−1)/2 зсувів. На тисячі елементів це 1 000 проти майже пів мільйона: різниця не в рази, а в порядок величини.

Лишається питання, чому append дешевий. Масив же суцільний: коли місце скінчиться, доведеться просити в системи більший шматок памʼяті й переносити туди все. Хіба це не дорого?

Дорого — але рідко. Список ніколи не виділяє памʼять по одній комірці. Коли місце закінчується, він просить із запасом, приблизно на восьму частину більше, ніж потрібно зараз. У CPython це буквально одна формула:

ємність = (n + n // 8 + 6), заокруглене вниз до кратного 4

Тут n — потрібна нова довжина, n // 8 — той самий запас у 12,5%, а + 6 дає невеликим спискам мінімальний буфер, щоб перші кілька append точно не викликали перевиділення. Підстав n = 1: 1 + 0 + 6 = 7, униз до кратного чотирьох — 4. Список із одного елемента одразу має чотири комірки, три з них порожні. Покрути повзунок і подивись, як ця формула працює далі:

Інтерактив 7 · Як список росте блоками

Суцільні комірки — зайняті, пунктирні — виділені про запас. Унизу — крива ємності.

довжина
ємність
перевиділень
копій на append
Сходинки, а не пряма. Ємність стрибає на 4, 8, 16, 24, 32, 40, 52, 64 — і між стрибками append просто кладе значення у вже готову комірку. Оскільки запас пропорційний розміру, перевиділення стають дедалі рідшими, і середня ціна одного append лишається сталою.

Це називають амортизованою складністю O(1): окремий append зрідка буває дорогим, але в середньому по багатьох викликах ціна стала. А от insert(0, x) ніякої амортизації не має — він чесно платить O(n) щоразу.

Коли додавати й забирати треба з обох кінців, списку варто пошукати заміну. У стандартній бібліотеці є collections.deque — двобічна черга на звʼязаних блоках. У неї appendleft і popleft коштують O(1) замість O(n):

from collections import deque черга = deque(["хліб", "молоко"]) черга.appendleft("мед") # O(1), а не O(n)

Плата — втрачений швидкий доступ до середини: черга[5000] у deque вже не константа. Виграв на кінцях — програв у середині.

операціяскладністьчому саме так
покупки[i]O(1)адреса рахується арифметикою
append(x)O(1) аморт.місце майже завжди вже виділено
pop()O(1)з кінця — нічого зсувати
insert(0, x)O(n)усі елементи їдуть на комірку вправо
pop(0)O(n)усі елементи їдуть на комірку вліво
remove(x)O(n)спершу пошук, потім зсув
x in покупкиO(n)перебір, поки не знайдеться
sort()O(n·log n)Timsort

Останній рядок варто виділити окремо. Перевірка x in покупкилінійна: Python перебирає елементи, поки не натрапить на потрібний. На списку з мільйона рядків це помітно повільно. Для частих перевірок «чи є таке» існує множина (тема 09), де ця сама операція коштує O(1).

11 / ПідсумокЩо забрати з теми

Список — упорядкована змінювана послідовність посилань. З цього одного речення випливає все, що ми розібрали:

У темі 07 ми візьмемо найближчого родича списку — кортеж (tuple). Виглядає майже так само, тільки в круглих дужках, а поводиться протилежно: змінити його неможливо. І питання буде саме те, яке ти вже вмієш ставити: навіщо комусь навмисно незмінна версія списку? Відповідь повʼязана і з безпекою від випадкових змін, і зі швидкістю, і зі словниками з теми 08.

Далі в практиці. У practice.ipynb ти зібереш робочий список покупок з нуля, зловиш пастку копії власними руками — і перевіриш кожен свій висновок через assert, який мовчить, коли все правильно, і кричить, коли ні.

Далі в темі

Теорію прочитано. Тепер закріпи її на практиці.