Wszystkie artykuły

KajoData / Python

Listy w Pythonie: indeksy, slicing i pułapki kopiowania

Rozkładam zapis listy na konkretne pozycje i wyniki. Pokazuję też, kiedy wycinek tworzy nową listę, a kiedy zmiana nadal dotknie wspólnego obiektu.

Kajo RudzińskiOpublikowano Aktualizacja 11 min czytania

Rama unosi trzy sąsiadujące klocki ze środka uporządkowanego szeregu.

Lista w Pythonie trzyma elementy w określonej kolejności. Mogę wybrać jedną pozycję, kilka kolejnych albo co drugi element. To podstawowa umiejętność, która wraca przy odczycie plików, dzieleniu pracy na partie i przygotowywaniu małych przykładów do analizy. Wolę dobrze rozumieć te kilka nawiasów, niż zapamiętywać kolejne kombinacje znaków z przypadkowych odpowiedzi w internecie.

Pokażę indeksowanie i slicing na jawnych danych syntetycznych. Przykłady sprawdziłem w Pythonie 3.14.4; nie wymagają dodatkowych bibliotek. Każdy blok ma własne dane, więc możesz uruchamiać je osobno. Będę mówił o zwykłej wbudowanej liście. Podobny zapis w pandas albo NumPy może mieć inne zasady dotyczące indeksów i kopiowania, więc nie przenoś tych wniosków automatycznie na każdą strukturę z nawiasami.

Jedna pozycja: numerowanie zaczyna się od zera

Załóżmy, że mamy sześć identyfikatorów plików w kolejności odczytu. Pierwszy ma indeks zero, drugi jeden, a szósty pięć. Indeks opisuje pozycję w bieżącej liście. Nie jest identyfikatorem biznesowym ani liczbą zapisaną w samym elemencie. To rozróżnienie przydaje się, gdy elementem jest na przykład napis "007" albo słownik z polem id.

# example: positions
items = ["A", "B", "C", "D", "E", "F"]
print(items[0], items[2], items[-1], items[-2])
print(len(items), items[len(items) - 1])

Wyniki to A C F E oraz 6 F. Ujemne indeksy liczą pozycje od końca: -1 wskazuje ostatni element, a -2 przedostatni. W starej wersji przykładu ostatni element opisano poprawnie, ale kod ponownie używał [2]. Taki kod wybiera trzeci element niezależnie od podpisu. Najprostsza kontrola to wypisanie wyniku na liście, której wartości łatwo rozróżnić.

Dodatni indeks ostatniego elementu to długość minus jeden. Sama długość jest już poza listą. Dla pustej listy nie istnieje ani pierwszy, ani ostatni element. Jeśli operacja dopuszcza pusty wynik, trzeba określić, co wtedy zrobić: zwrócić brak, pominąć etap czy zgłosić naruszenie założeń. Dopięcie [-1] do dowolnej listy nie daje automatycznej obsługi pustki.

Wycinek wybiera zakres z wyłączonym końcem

Zapis items[start:stop] tworzy listę elementów od pozycji początkowej do pozycji końcowej bez niej. Przy dodatnim kroku items[1:4] wybiera indeksy jeden, dwa i trzy. Ja czytam to jako „zacznij na jeden, zatrzymaj się przed cztery”. Dzięki temu granica jednego fragmentu może być początkiem kolejnego, bez ręcznego odejmowania jedynki przy każdym podziale.

# example: slices
items = ["A", "B", "C", "D", "E", "F"]
print(items[1:4])
print(items[:3], items[3:])
print(items[:3] + items[3:] == items)
print(items[2:3], items[2])

Pierwszy wynik to ['B', 'C', 'D']. Dwa kolejne fragmenty to ['A', 'B', 'C'] i ['D', 'E', 'F']; ich połączenie odtwarza całość, więc porównanie daje True. Ostatnia linia pokazuje ważną różnicę: ['C'] jest jednoelementową listą, a C pojedynczym napisem. Kod oczekujący listy może potrzebować wycinka nawet wtedy, gdy wybierasz tylko jedną pozycję.

Pominięty początek przy dodatnim kroku oznacza początek listy, a pominięty koniec pozwala dojść do jej końca. Dwukropek jest częścią składni wycinka. items[3] i items[3:] mają inne wyniki oraz zwykle inne zastosowania. Dokładną semantykę opisują operacje na sekwencjach. W praktyce proponuję zacząć od małej listy liter i zapisać wybrane indeksy na kartce.

Krok pozwala pomijać elementy lub iść od końca

Pełny zapis to start:stop:step. Domyślny krok wynosi jeden. Krok dwa odwiedza co drugą pozycję, licząc od wskazanego początku. Ujemny krok zmienia kierunek przechodzenia. Jego znak ma znaczenie przy interpretacji pominiętych granic, dlatego reguły „brak początku to zawsze zero” nie należy używać dla wycinków wstecznych.

# example: steps
items = ["A", "B", "C", "D", "E", "F"]
print(items[::2], items[1::2])
print(items[::-1])
print(items[4:1:-1])
print(items[4:-1:-1], items[4::-1])

Dostaniesz ['A', 'C', 'E'] oraz ['B', 'D', 'F']. Wycinek [::-1] zwróci ['F', 'E', 'D', 'C', 'B', 'A']. Wariant [4:1:-1] przejdzie przez pozycje cztery, trzy i dwa, czyli ['E', 'D', 'C']. Końcowa pozycja jeden pozostaje wyłączona również przy przechodzeniu wstecz.

Ostatnia linia daje [] i ['E', 'D', 'C', 'B', 'A']. Jawne -1 oznacza pozycję ostatniego elementu, a nie umowną pozycję przed początkiem listy. Zaczynając na cztery i idąc wstecz, nie dojdziemy w żądanym kierunku do granicy odpowiadającej ostatniej pozycji. Jeśli chcę dojść do samego początku, zostawiam granicę pustą. To jeden z powodów, dla których wolę czytelny wycinek od efektownej kombinacji ujemnych liczb.

Pusty wycinek jest poprawnym wynikiem

Pojedynczy indeks poza zakresem zgłasza IndexError. Wycinek zachowuje się łagodniej: granice są dostosowywane do długości sekwencji. Można poprosić o pierwszych sto elementów listy mającej trzy elementy i otrzymać te trzy. Nie oznacza to jednak, że dostało się dokładnie sto rekordów. Jeżeli taka liczba jest wymaganiem, trzeba osobno sprawdzić długość wyniku.

# example: boundaries
items = ["A", "B", "C"]
print(items[:100], items[100:], items[2:1])
print([][:3])
try:
    print(items[3])
except IndexError:
    print("index out of range")
try:
    print(items[::0])
except ValueError:
    print("zero step")

Wyniki pokazują pełną listę, dwa puste wycinki, pustą listę oraz dwa opisane błędy. Krok zero jest niedozwolony. Równe granice albo kierunek niezgodny z krokiem mogą dać pustą listę. Pustka nie musi oznaczać awarii, ale może ujawniać błędne założenie o danych. Sprawdzam ją w kontekście zadania, zamiast automatycznie otaczać każdy wycinek obsługą wyjątków.

Szczególna pułapka dotyczy items[-n:]. Dla dodatniego n wybiera końcówkę. Dla n=0 zapis -0 jest po prostu zerem, więc wynikiem jest cała lista. Jeżeli funkcja ma zwracać zero ostatnich elementów, obsłuż ten przypadek jawnie. Taki błąd łatwo przeoczyć, testując wyłącznie typowy przykład z trzema rekordami.

Nowa lista i nowe elementy to dwie różne decyzje

Wycinek zwykłej listy tworzy nową listę, ale zawiera odwołania do tych samych obiektów elementów. Przy napisach i liczbach często nie widać konsekwencji, bo tych obiektów nie modyfikujemy w miejscu. Przy słownikach i listach zagnieżdżonych różnica staje się bardzo konkretna. Warto rozumieć ją przed przygotowaniem „bezpiecznej kopii” danych do eksperymentów.

# example: shallow-copy
rows = [{"id": "A", "tags": ["raw"]}, {"id": "B", "tags": []}]
selected = rows[:1]
print(selected is rows, selected[0] is rows[0])
selected[0]["tags"].append("checked")
print(rows[0]["tags"])
selected.append({"id": "C", "tags": []})
print(len(rows), len(selected))

Wynik False True mówi, że listy są różne, ale pierwszy słownik jest wspólny. Dopisanie tagu będzie widoczne także przez rows: ['raw', 'checked']. Dopisanie nowego elementu do selected zmienia natomiast tylko tę zewnętrzną listę. Obie mają teraz po dwa elementy, choć nie zawierają tych samych rekordów. Sama zgodna długość nie dowodzi identycznej zawartości.

Dokumentacja copy wyjaśnia różnicę między kopiowaniem płytkim i głębokim. deepcopy może być właściwe, gdy rzeczywiście potrzebujesz niezależnych zagnieżdżonych danych. Nie stosuję go automatycznie do wszystkiego. Czasem chcę zachować współdzielone obiekty, a czasem wystarczy zbudować nowy słownik tylko z pól, które podlegają zmianie. Najpierw ustalam oczekiwany skutek.

Dwie osobne tacki zawierają pojemniki połączone wspólnym mostkiem, symbolizując współdzielone elementy.

Przypisanie do wycinka zmienia istniejącą listę

Po lewej stronie przypisania wycinek działa inaczej niż przy samym odczycie. items[1:3] = [...] zastępuje wskazany fragment istniejącej listy. Dla zwykłego, ciągłego wycinka liczba nowych elementów może być inna niż liczba usuwanych. Lista może się skrócić albo wydłużyć, a wszystkie nazwy odwołujące się do niej zobaczą zmianę.

# example: slice-assignment
items = ["A", "B", "C", "D"]
alias = items
items[1:3] = ["X"]
print(items, alias is items)
items[1:1] = ["Y", "Z"]
print(items)
del items[1:3]
print(items)

Najpierw otrzymasz ['A', 'X', 'D'] i True. Następnie wstawienie w pusty wycinek da ['A', 'Y', 'Z', 'X', 'D'], a usunięcie pozycji jeden i dwa przywróci ['A', 'X', 'D']. Wstawienie nie wymaga uprzedniego tworzenia nowej listy. Ta wygoda oznacza jednak, że mutacja może być widoczna w kilku miejscach programu.

Przy przypisaniu do rozszerzonego wycinka z krokiem innym niż jeden liczba podawanych elementów musi pasować do liczby wybranych pozycji. Nie można swobodnie zmieniać długości w takim przypisaniu. Jeśli kod ma tylko przygotować podzbiór do wyświetlenia, odczyt wycinka zwykle wystarczy. Modyfikowanie wejścia warto zostawić sytuacjom, w których jest to jasno zamierzony efekt funkcji.

Pozycja w liście nie zastępuje warunku na danych

Slicing wybiera pozycje. Nie sprawdza, które zadania są gotowe, które pomiary przekraczają próg ani które daty należą do miesiąca. Do wyboru według treści elementu używam warunku, na przykład w list comprehension. Te operacje można łączyć, ale kolejność zmienia pytanie, na które odpowiada wynik.

# example: filter-order
rows = [
    {"id": "A", "ready": False},
    {"id": "B", "ready": True},
    {"id": "C", "ready": True},
    {"id": "D", "ready": True},
]
first_two_ready = [row for row in rows if row["ready"]][:2]
ready_in_first_two = [row for row in rows[:2] if row["ready"]]
print([row["id"] for row in first_two_ready])
print([row["id"] for row in ready_in_first_two])

Pierwszy wynik to ['B', 'C'], drugi ['B']. Najpierw filtrując, proszę o pierwsze dwa gotowe zadania. Najpierw przycinając, pytam o gotowe zadania wśród dwóch początkowych rekordów. Obydwa wyniki są poprawne dla swoich pytań. Przy przeglądzie kodu czytam więc cały ciąg operacji, a nie oceniam pojedynczego wycinka w oderwaniu od reszty.

Podobnie „ostatnie pięć” oznacza pięć pozycji końcowych w obecnym uporządkowaniu. Jeśli lista nie jest posortowana według daty, nie dostaniesz automatycznie pięciu najnowszych zdarzeń. Dane z pliku mogą być uporządkowane według identyfikatora albo kolejności zapisu. Ustal porządek przed wyborem fragmentu i zdefiniuj rozstrzyganie remisów, gdy ma znaczenie dla powtarzalnego raportu.

Podział na partie wymaga sprawdzenia końcówki

Praktyczny przykład to dzielenie listy identyfikatorów na niewielkie partie. Mogę przejść przez początki fragmentów funkcją range, a każdy fragment pobrać wycinkiem. Ostatnia partia może być krótsza. Nie dopisuję fikcyjnych wartości, żeby wszystkie fragmenty wyglądały równo, i nie pomijam końcówki tylko dlatego, że nie mieści się w pełnej grupie.

# example: batches
items = ["A", "B", "C", "D", "E", "F", "G"]
size = 3
if size <= 0:
    raise ValueError("size must be positive")
batches = [items[start:start + size] for start in range(0, len(items), size)]
print(batches)
print([item for batch in batches for item in batch] == items)

Wyniki to [['A', 'B', 'C'], ['D', 'E', 'F'], ['G']] i True. Odtworzenie pierwotnej listy ze wszystkich partii jest prostą kontrolą braku pominięć i zmiany kolejności. Dla pustej listy wynikiem będzie pusta lista partii. Jeśli odbiorca oczekuje zawsze co najmniej jednej grupy, to dodatkowe wymaganie trzeba opisać osobno.

Ten przykład przechowuje wszystkie partie w pamięci. Dla dużego strumienia danych rozważ generator albo przetwarzanie partii od razu w pętli. Zwykły wycinek listy tworzy nową zewnętrzną listę, więc nie jest darmowym widokiem na dowolnie duże dane. Nie podaję uniwersalnego progu rozmiaru: zależy od wielkości elementów, dostępnej pamięci i dalszych operacji.

Odwrócenie kolejności i sortowanie odpowiadają na inne pytania

Wycinek [::-1] odwraca aktualny porządek. Nie porządkuje liczb od największej ani tekstów alfabetycznie. Jeżeli wejście ma wartości trzy, jeden, dwa, odwrócenie da dwa, jeden, trzy. Sortowanie malejące da trzy, dwa, jeden. Przed użyciem którejkolwiek operacji zapisuję, jaki porządek ma mieć wynik i czy obecna kolejność niesie informację, którą chcę zachować.

Lista ma także metodę reverse, która odwraca ją w miejscu i zwraca None. Zapisanie wyniku tej metody do nazwy listy pozbawi Cię pod tą nazwą dostępu do listy. Metoda wykonała pracę, ale nie oddała nowej kolekcji. Wariant z wycinkiem zwraca nową listę, a funkcja reversed daje iterator. Dobieram postać do kolejnego kroku, zamiast traktować podobne nazwy jako wymienne instrukcje.

# example: reverse-and-sort
values = [3, 1, 2]
print(values[::-1], sorted(values, reverse=True))
result = values.reverse()
print(values, result)
print(list(reversed(values)))

Wyniki to [2, 1, 3] i [3, 2, 1], potem [2, 1, 3] None, a na końcu [3, 1, 2]. Przykład pokazuje, dlaczego warto kontrolować także wartość zwracaną przez metodę. W notebooku zmiana wejścia może wpływać na komórki uruchamiane później, choć ich treść pozostała bez zmian. Ponowne uruchomienie od początku pomaga wykryć takie zależności od stanu.

Uzgadniam, czy podzbiór ma być migawką

Wyobraź sobie syntetyczną kolejkę zadań, z której wybierasz pierwsze trzy rekordy do wyświetlenia. W czasie dalszego przetwarzania ich statusy mogą się zmienić. Jeśli podzbiór zawiera wspólne słowniki, pokaże nowe statusy. Jeśli miał przedstawiać stan w chwili wyboru, potrzebujesz świadomie przygotowanej migawki. Oba zachowania mogą być pożądane, ale powinny wynikać z umowy funkcji.

Dla prostych danych czasem wystarczy skopiować wybrane pola do nowych słowników. Taki krok pozwala także ograniczyć wynik do informacji potrzebnych odbiorcy. Jeżeli zostawiasz zagnieżdżoną listę tagów, samo utworzenie nowego słownika nadal nie skopiuje tej listy. Rysuję wtedy mały schemat: nazwa, lista zewnętrzna, rekord, lista tagów. Dla każdego poziomu zaznaczam, co ma pozostać wspólne.

Płytka kopia może więc być zupełnie poprawna dla kolejki bieżącej, a niewystarczająca dla historii zmian. Nie opisuję jej ogólnie jako błędu ani nie obiecuję, że głęboka kopia rozwiąże każdy problem. Zewnętrzne zasoby i obiekty z własnymi regułami kopiowania wymagają osobnego namysłu. W tym artykule pracujemy na małych listach, słownikach i napisach, więc możemy dokładnie wskazać granice naszych przykładów.

Podobną ostrożność zachowuję przy przenoszeniu kodu do innych bibliotek. Wycinek listy, widok tablicy NumPy i wybór danych w pandas mają różne kontrakty. Znajoma składnia nie daje gwarancji identycznego skutku. Najpierw sprawdzam typ obiektu i dokumentację konkretnej operacji, a potem uruchamiam przykład z modyfikacją. Taki test szybciej ujawnia współdzielenie niż samo porównanie wydrukowanych wartości.

Jak sprawdzam kod z wycinkami

Zaczynam od listy, której elementy są różne, i ręcznie zapisuję oczekiwane pozycje. Powtarzające się wartości potrafią ukryć błąd indeksu. Jeśli trzy kolejne elementy to liczba siedem, wybór niewłaściwego z nich nadal wypisze siedem. Dopiero lista prostych etykiet pokaże, czy operacja przesunęła granicę o jedną pozycję.

Następnie sprawdzam pustą listę, jeden element, granicę równą długości i granicę wykraczającą poza długość. Dla kroku sprawdzam oba kierunki oraz niedozwolone zero. Jeśli kod modyfikuje elementy, do próbki dokładam słownik albo zagnieżdżoną listę. Dzięki temu test pokaże współdzielenie, którego nie zobaczę na samych liczbach i napisach.

Nie usuwam elementów z tej samej listy w trakcie zwykłego przechodzenia po niej, jeśli nie mam bardzo konkretnego powodu i kontroli indeksów. Po usunięciu kolejne pozycje się przesuwają, więc łatwo ominąć rekord. Przy filtrowaniu zwykle buduję nową listę. To czytelne rozwiązanie, a dopiero zmierzone ograniczenia pamięci uzasadniają bardziej skomplikowany wariant.

Ćwiczenie: ostatnie zadania bez pułapki z zerem

Napisz funkcję zwracającą ostatnie n elementów, z następującą umową: n jest nieujemną liczbą całkowitą, zero daje pustą listę, a liczba większa od długości daje całość. Funkcja ma zwracać nową listę i nie zmieniać wejścia. Zacznij od przewidzenia wyniku dla sześciu liter, zanim uruchomisz kod.

# example: last-items
def last_items(items, n):
    if isinstance(n, bool) or not isinstance(n, int) or n < 0:
        raise ValueError("n must be a nonnegative integer")
    return items[-n:] if n else []

items = ["A", "B", "C", "D", "E", "F"]
print(last_items(items, 2), last_items(items, 0))
print(last_items(items, 20), last_items([], 2))

Wyniki to ['E', 'F'], [], pełna lista oraz []. Odrzucam również True, mimo że Python traktuje typ logiczny jako podtyp liczby całkowitej. W tym interfejsie chcę konkretnej liczby elementów, więc zapisuję taką decyzję jawnie. Sprawdź osobno -1, 1.5 i True, a potem upewnij się, że wejściowa lista pozostała taka sama.

Gdy potrafisz wyjaśnić te wyniki, możesz przejść do zliczania elementów przez słownik i Counter albo wykorzystać wycinki w odczycie plików. Najbardziej użyteczna umiejętność to przewidzenie, które konkretne elementy dostaniesz i co zostanie zmienione. Sam zapis z dwoma dwukropkami nie robi jeszcze z kodu dobrej analizy.

Najważniejsze w pigułce

Czy końcowy indeks wycinka należy do wyniku?

Nie. W zapisie start:stop:step pozycja stop jest wyłączona. Dla dodatniego kroku lista[1:4] wybiera indeksy 1, 2 i 3.

Dlaczego lista[-1] działa, a lista[len(lista)] zgłasza błąd?

Dla niepustej listy -1 oznacza ostatni element. Dodatnie indeksy kończą się na len(lista)-1, więc len(lista) leży poza listą.

Czy lista[:] kopiuje także zagnieżdżone słowniki?

Tworzy nową listę z odwołaniami do tych samych elementów. Zagnieżdżone słowniki pozostają wspólne; ich niezależne kopiowanie wymaga osobnej decyzji.

Przy pracy nad artykułem użyto w zautomatyzowany sposób sztucznej inteligencji. Jeśli też chcesz tak umieć, sprawdź ten projekt.

Kajo Rudziński

Newsletter KajoData

Zostańmy w kontakcie.

Piszę o analizie danych, uczeniu się i zmianie pracy. Dzielę się tym, co pomaga mi ogarniać technologię i zawodowe decyzje. Jeśli lubisz ten sposób myślenia, zapraszam do mojego newslettera.