W moim poprzednim artykule mogłeś przeczytać o tym, że przypisanie zmiennej do innej zmiennej nie utworzy kopii wartości, ale kopię odwołania. Jest to ważne zwłaszcza podczas przekazywania listy jako argumentu funkcji.
Teraz przedstawię różne sposoby na to, aby utworzyć kopię np. listy, słownika tak, żeby stanowiła nowy obiekt . Ponadto dowiesz się, jak zrobić dict lub set comprehension.

Spis treści:

Kopiowanie list i słowników

Dla przypomnienia prosty przykład:

def foo(lista):
    for index,item in enumerate(lista):
        lista[index]=item+1
    return lista

a=[1,2,3,4]
b=foo(a)

>>>a,b
([2, 3, 4, 5], [2, 3, 4, 5])

Do zmiennej b przypisaliśmy funkcję foo(), która za argument przyjmuje listę a. Jak widzisz, wartość zmiennej a się zmieniła!

Pokażę co zrobić, żeby uniknąć takiej sytuacji. W zasadzie większość sposobów. które przedstawię, można wykorzystać na wszystkich mutowalnych obiektach, ale skupię się na listach i słownikach.

  1. List/Dict/Set Comprehension
    O list comprehension możesz poczytać tutaj.
  2. Wycinki [ang. slices] – coś, co musi umieć każdy Pythonista 🙂
    Ogólny wzór list[i:j], gdzie i i j stanowią numer indeksów początku i końca zakresu, który pobieramy.
    lista=[1,2,3,4,5,6]
    copy1=lista[:]
    copy2=lista[0:len(lista)]
    Indeksowanie nie działa dla słowników i zbiorów, ale zadziała przy krotkach, tablicach, łańcuchach znaków. Oczywiście nie oznacza to, że nie możemy wyciąć fragmentu słownika, tylko trzeba troszkę więcej pomyśleć, jak to zrobić 🙂 Na przykład można użyć dict comprehension na skonwertowanym do listy słowniku (dict2 = {k:v for k,v in list(dict1.items())[0:2]})
  3. Wypakowanie za pomocą *args (listy, zbiory), **kwargs (słowniki)
    zbior={1,5,6,2,8}
    zbior_copy={*zbior}

    slownik={"name": "Wojtek", "age": 42}
    slownik_copy={**slownik}

    Więcej o * i ** przeczytasz tutaj.

  4. Wbudowana metoda copy()
    Listy, zbiory i słowniki posiadają wbudowaną funkcję do kopiowania.
    kopia1 = zbior.copy()
    kopia2 = lista.copy()
    kopia3 = slownik.copy()

    Tablice również posiadają takie funkcje: __copy__() oraz __deepcopy__()
    array_kopia=tablica.__copy__()
  5. Wbudowany moduł copy – import copy
    copy.copy() – płytkie kopiowanie
    copy.deepcopy() – głębokie kopiowanie, kiedy mamy zagnieżdżone sekwencje
  6. Konwersja
    kopia1=set(zbior)
    kopia2=list(lista)
    kopia3=dict(slownik)
  7. Pomnożenie *1
    Szybki i fajny sposób:
    nowa_tablica=stara_tablica*1
    Nie zadziała przy słownikach (TypeError).
  8. Tradycyjna pętla
    Coś co pewnie już znasz, czyli stworzenie pustej listy/słownika/zbioru i dodawanie elementów przy użyciu odpowiedniej metody. Przy czym jest to zdecydowanie jeden z najwolniejszych sposobów.

Pewnie można jeszcze wymyślić kilka sposobów, ale te są najpopularniejsze. Przy krótkich sekwencjach, to który sposób wybierzemy, nie ma aż tak dużego znaczenia. Dopiero przy kilku milionowych listach zaczynają się pojawiać różnice

Dict/Set Comprehension

Jeśli tworzysz słownik przy użyciu pętli, to zapewne robisz to pewnie mniej więcej tak:

klucze = ["imię", "nazwisko", "wiek", "płeć"]
dane_input = ["Ania", "Baranowska", "22", "K"]
student={}

for index, k in enumerate(klucze):
    student[k]= dane_input[index]
    
print(student)

>>>
{'imię': 'Ania', 'nazwisko': 'Baranowska', 'wiek': '22', 'płeć': 'K'}

Inny przykład:

slownik= {}

for number in range(1,16):
   slownik[f"img{number}"]=number

print(slownik)

>>>
{'img1': 1, 'img2': 2, 'img3': 3, 'img4': 4, 'img5': 5, 'img6': 6, 'img7': 7, 'img8': 8, 'img9': 9, 'img10': 10, 'img11': 11, 'img12': 12, 'img13': 13, 'img14': 14, 'img15': 15}

Zamiast iteracji w normalnej pętli, można to zastąpić dict comprehension, które jest szybszym sposobem na tworzenie słowników.
Ogólna zasada, jeśli iterujemy po słowniku. :
dictionary={key: value for key, value in dictionary.items()}

Dla powyższych przykładów iterujemy po liście:
some_dict={k:dane[index] for index, k in enumerate(lista1)}

slownik={f"img{number}":number for number in range(1,16)}

Set comprehension wygląda prawie tak samo jak list comprehension z tą różnicą, że zamiast nawiasów kwadratowych używamy nawiasy klamrowe.

lista=[0,1,3,0,2,2,3,1,0,18]
zbior={n for n in lista if n<10}
print(zbior)

>>> {0, 1, 2, 3}

Który sposób kopiowania jest najlepszy?

Wyniki moich testów (w sekundach):

Dane wejściowe:
– prosta lista [0,1,2,3,4,5,6,7,8,9]
– 1000000 powtórzeń
Dane wejściowe:
– numpy array 2D [[1,2,3,4,5,6,7,8,9]]– 1000000 powtórzeń
• *args => 0.13550057200000154
*1 => 0.14948825799999987
extend() => 0.15869937799999967
[:] => 0.16317167699999935
Wbudowane copy() => 0.1651740789999998
Konwersja list() => 0.2150672880000002
[0:len(numbers)] => 0.22458101999999958
copy.copy() => 0.424305822
List comprehension => 0.45756895399999997
Zwykła pętla => 0.6951828100000004
• copy.deepcopy() => 6.867170623
• [:] => 0.20066541499999957
• [0:len(numbers)] => 0.27796386600000034
• copy.copy() => 0.27796386600000034
• Wbudowane copy() => 0.43114273099999956
• Konwersja => 0.45966505400000024
• *1 => 0.7387513000000006
• np.copy => 1.4535552089999992
• copy.deepcopy() => 1.771366166
• *args => 2.9307604929999993
• List comprehension => 3.295802734
Dane wejściowe:
– lista z losowo wygenerowanych liczb całkowitych (zasięg 7000000, 1000000 unikatowych elementów)
– 100 powtórzeń
Dane wejściowe:
– numpy array 2D z losowo wygenerowanych liczb całkowitych (zasięg 7000000, 1000000 unikatowych elementów)
– 100 powtórzeń
• extend() => 1.6274941049999967
*args => 1.632297819999991
Wbudowane copy() => 1.6339741050000072
*1 => 1.6362218410000082
[0:len(numbers)] => 1.6367383349999969
copy.copy() => 1.6369949259999999
Konwersja list() => 1.637231319999998
[:] => 1.6390549370000116
List comprehension => 5.099412953
Zwykła pętla => 7.922438999000008
• copy.deepcopy() => 55.360801372000005
• [:] => 2.4499999999871847e-05
[0:len(numbers)] => 5.429800000000817e-05
copy.copy() => 5.429800000000817e-05
Konwersja => 0.12598584700000015
copy.deepcopy() => 0.126217276
*args => 0.12645201499999992
np.copy => 0.12694135899999992
Wbudowane copy() => 0.12762339500000008
List comprehension => 0.12887158600000004
• *1 => 0.16013529500000012

Powyższe wyniki wskazują na to, że warto skorzystać z tablic numpy, jeśli mamy bardzo dużo elementów. Numpy osiągnął duże lepsze osiągi przy 1000000 unikatowych elementów niż listy. W przypadku numpy najlepszym sposobem skopiowania okazały się wycinki. Z kolei widać też, że list comprehension nie należy do najszybszych metod i zazwyczaj plasował się gdzieś pod koniec zarówno dla list jak i dla numpy.arrays . Niemniej technika kopiowania powinna zależeć od obiektu oraz od tego, co chcemy osiągnąć. Na przykład jeśli masz listę ze stringami i chciałbyś stworzyć drugą taką samą, gdzie wszystkie stringi zaczynałyby się od dużej litery, to użyjesz list comprehension.
Tutaj znajdziesz kody, jeśli chciałbyś samemu zmierzyć czas wykonywania. Pamiętaj, że wyniki zależą od mocy twojego procesora.

Shallow copy vs deep copy

Prawie wszystkie wymienione przeze mnie techniki kopiowania stosują tzw. płytkie kopiowanie (shallow copy). Różnica między płytkim a głębokim kopiowaniem (deep copy) dotyczy obiektów złożonych, czyli obiektów zawierających inne obiekty, takich jak listy lub instancje klas (instancja to inaczej obiekt). Innymi słowy płytkie kopiowanie nie jest odpowiednie do skopiowania zagnieżdżonych list czy słowników. Oto prosty przykład:

matrix=[[1,1,1],
        [1,1,1],
        [1,1,1]]

matrix_copy=matrix*1
matrix_copy+=[[1,1,1]]
print(matrix)
print(matrix_copy)

>>>
[[1, 1, 1], [1, 1, 1], [1, 1, 1]]
[[1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1]]

W zmiennej matrix_copy utworzyliśmy kopię matrix. Następnie do nowej zmiennej dodaliśmy kolejną listę. Jak widać oryginalna lista się nie zmieniła. Niestety w przypadku, kiedy chcemy dokonać zmiany wewnątrz zagnieżdżonej listy, zmieni się także pierwsza lista:

matrix_copy[0][0]=0
print(matrix)
print(matrix_copy)

>>>
[[0, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1]]
[[0, 1, 1], [1, 1, 1], [1, 1, 1]]

W powyższym programie dokonaliśmy zmian w kopii, czyli matrix_copy[0][0]=0. Zmienione zostały obie listy. ponieważ odwołują się do tego samego zagnieżdżonego obiektu. Dlatego w takich przypadkach potrzebujemy copy.deepcopy().

import copy
matrix_deepcopy=copy.deepcopy(matrix)
matrix_deepcopy[0][1]=0
print(matrix)
print(matrix_deepcopy)

>>>
[[0, 1, 1], [1, 1, 1], [1, 1, 1]]
[[0, 0, 1], [1, 1, 1], [1, 1, 1]]

Alternatywą jest użycie list comprehension:
matrix_deepcopy2=[item[:] for item in matrix]

Tylko, że im więcej zagnieżdżeń, tym również bardziej zagnieżdżona pętla, co powoduje nieczytelność kodu.

Pułapka w deepcopy()

Niestety, ale deepcopy() czasami może nie zadziałać, tak jakbyśmy sobie życzyli. Gdybyśmy naszą macierz utworzyli w następujący sposób:

m= 3*[3*[1]]
new_m = copy.deepcopy(m)
print(new_m)
new_m[1][2] = 100
print(new_m)
print(id(new_m[0]),id(new_m[1]),id(new_m[2]))

>>>
[[1, 1, 1], [1, 1, 1], [1, 1, 1]]
[[1, 1, 100], [1, 1, 100], [1, 1, 100]]
164122440 164122440 164122440

Zmieniliśmy każdą zagnieżdżoną listę, chociaż chcieliśmy dokonać zmian tylko w jednej. Funkcja id() pokazała, że każda zagnieżdżona lista jest tym samym obiektem.
Głębokie kopiowanie tworzy nowy złożony obiekt, a następnie rekurencyjnie wstawia do niego kopie obiektów znalezionych w oryginale. deepcopy() prowadzi słownik („memo”) zawierający wszystkie obiekty, które zostały już skopiowane. Ma to na celu uniknięcie nieskończonych rekurencji. Dlatego kiedy w naszym programie deepcopy() próbuje wykonać głęboką kopię drugiej podlisty, widzi, że już ją wcześniej skopiował i po prostu wstawia jeszcze raz tę podlistę. deepcopy() uznaje wspólne odniesienia za celowe. W tym przypadku lepiej użyć list comprehension:

m= [[1]*3 for item in range(3)]
new_m = copy.deepcopy(m)
new_m[1][2] = 100
print(new_m)
print(id(new_m[0]),id(new_m[1]),id(new_m[2]))

>>>
[[1, 1, 1], [1, 1, 100], [1, 1, 1]]
164115336 164114824 164114632

Dzięki funkcji id() widzimy, że teraz każda zagnieżdżona lista jest nowym obiektem.

Ćwiczenie – mini projekt

W celu uatrakcyjnienia tematu postanowiłam przygotować ćwiczonko, w którym zrobimy uproszczoną symulację odtwarzania playlisty.
Naszą bazą piosenek będzie przygotowana przeze mnie lista piosenek zapisana w pliku tekstowym. Plik, jak i cały kod można pobrać z naszego repozytorium. Jeśli komuś się nie chce pobierać, to tak wygląda moja baza:

path1.mp3;Michael Jackson;Care About Us;4
path2.mp3;Dire Straits;Money For Nothing;5
path3.mp3;Black Violin;A Flat;11
path4.mp3;Ludwig van Bethoven;Dla Elizy;6
path5.mp3;Tom Walker;Leave a Light On;15
path6.mp3;Lady Pank;Stacja Warszawa;3
path7.mp3;Evanescence;Bring Me To Life;21
path8.mp3;Gnus;Numb;19
path9.mp3;Robin Schulz ft. James Blunt;Ok;18
path10.mp3;Robin Schulz ft. Francesco Yates;Sugar;16
path11.mp3;Abstract Future;Nothing To Lose;17
path12.mp3;Chuck Berry;Johnny B Good;11
path13.mp3;Kenn Colt ft. Nari Milani;Come Back To Me Official;10
path14.mp3;Sublime;What I Got;9
path15.mp3;System of a Down;Roulette;5
path16.mp3;Piotr Czajkowski;Jezioro łabędzie;3
path17.mp3;Mary J Blige ft. U2;One;14
path18.mp3;Sia;Never Give Up;20
path19.mp3;Incubus;Drive;10
path20.mp3;Lindsay Stirling;Underground;15
path21.mp3;Lindsay Stirling;Arena;17

Oczywiście możecie wpisać swoje piosenki, jeśli chcecie 🙂 Nie będziemy naprawdę odtwarzać piosenek. Jeśli ktoś chciałby jednak to zrobić, to polecam skorzystać z takich bibliotek jak Kivy albo Pygame. Celem tego zadania jest przećwiczenie kopiowania list, list/dict comprehension oraz dodatkowo operacji na plikach 🙂
Każda linia w mojej bazie jest napisana wg następującego wzoru:
ścieżka dostępu do pliku;wykonawca,autor;tytuł piosenki;liczba odsłuchań
W skrócie co zrobimy:
1. Utworzenie klasy Sound, która będzie symulować odtwarzanie muzyki, czyli po prostu wyświetli napis „Gra muzyka”.
2. Utworzenie słownika, do którego wypakujemy dane z pliku. Zrobimy to używając dict comprehension.
3. Stworzenie playlisty zapisanej przez użytkownika z wybranych utworów z naszej bazy.
4. Zdefiniujemy funkcję, która będzie odtwarzać piosenki z zapisanej playlisty w losowej kolejności.
5. Zaktualizowanie liczby odsłuchań w bazie.

Nasza klasa Play powinna przyjmować ścieżkę dostępu do pliku. Ścieżka powinna być stringiem.

class Sound:
    """Ta klasa imituje odtwarzanie dźwięku"""
    
    def __init__(self,path):
        if isinstance(path,str)==True:
            print("Gra muzyka")
        else:
            print("Coś tu nie gra")

isinstance(obiekt, typ danych) sprawdza czy typ obiektu jest taki jak podany, jeśli tak to zwraca True, jeśli nie to False. Możemy także sprawdzić przy użyciu type(): if type(path)==str .

Teraz wypakujemy dane o piosenkach. Każda linia w tekście będzie wartością w słowniku. Linię podzielimy, tak żeby każda pojedyncza informacja oddzielona separatorem „;” była jednym elementem listy. Ponieważ absolutna ścieżka dostępu jest wartością unikatową, dlatego pokusiłam się o zrobienie z tego klucza w słowniku. W skrócie słownik będzie tworzony wg wzoru:
{ścieżka dostępu: [ścieżka dostępu,tytuł, wykonawca, liczba odsłuchań]}
Generalnie radziłabym sobie to gdzieś zapisać albo zapamiętać, ponieważ potem będziemy się odnosić do poszczególnych elementów list, które stanowią wartość dla kluczy słownika.

with open("baza_piosenek.txt","r",-1,"utf-8") as file:
    base_dict={line.strip().split(";")[0]:line.strip().split(";") for line in file.readlines()}

file.readlines() tworzy listę linijek tekstu. Przy pracy z plikami trzeba pamiętać, że każda linia jest stringiem, na końcu którego znajdują się białe znaki, dlatego trzeba użyć strip(). Przy pomocy split(„;”) rozdzielimy tekst i utworzymy listę. line.strip().split(";") jest już listą, dlatego [0] na tej liście odnosi się do stringu z naszą ściężką dostępu do piosenki.
Możesz sobie teraz wyprintować base_dict i zobaczyć, czy wszystko wygląda tak, jak powinno. Polecam stosowanie print() do sprawdzania, jak wyglądają poszczególne wartości w zmiennych. Może jest to funkcja, którą poznałeś na samym początku nauki Pythona, ale nie lekceważ print(). Jest jedna z funkcji, którą stosuje się podczas debugowania, dlatego, że pozwala śledzić przepływ (flow) danych.

playlist_base={
    "Różne":
[base_dict['path2.mp3'],base_dict['path7.mp3'],base_dict['path9.mp3'],           
base_dict['path10.mp3'],base_dict['path18.mp3'],base_dict['path19.mp3']],
    "Instrumental":
[base_dict['path3.mp3'],base_dict['path4.mp3'],base_dict['path8.mp3'],
base_dict['path16.mp3'],base_dict['path20.mp3'],base_dict['path21.mp3']]}

playlist_base przechowuje zapisane przez użytkownika playlisty. Generalnie też powinna być zapisana w odrębnym pliku i powinniśmy ją wypakowywać w momencie uruchomienia programu, ale na potrzeby tego ćwiczenia wystarczy, to co mamy. Jak widzisz kluczem w słowniku, jest podana przez użytkownika nazwy dla playlisty, a wartością jest lista odwołań do bazy z piosenkami.
Teraz stworzymy funkcję, która po wywołaniu będzie odtwarzać piosenki z podanej playlisty w losowej kolejności.

import random
import copy #jeśli użyjesz deepcoopy()

def play_random(playlista,baza):
    playlista=[*playlista] #lub copy.deepcopy(playlista)
    random.shuffle(playlista)
    for song in playlista:
        song[3]=int(song[3])+1 #song[3] to liczba odsłuchań
        #kluczem w słowniku z wszystkimi piosenkami jest ścieżka dostępu, którą przechowujemy także w song[0]
        #baza[song[0]][3]=int(baza[song[0]][3])+1 jeśli użyjesz deepcopy()
        song[3]=str(song[3]) #Konwertujemy z powrotem na string  
        Play(song[0]) #przekazujemy ściężkę dostępu piosenki
        print(f"Teraz gra piosenka '{song[2]}', autora {song[1]}, którą odsłuchujesz {song[3]} raz.")
        #Nadpisujemy naszą bazę w .txt
        values = [';'.join(item) for item in baza.values()]
        open('baza_piosenek.txt', 'w',-1,'utf-8').write('\n'.join(values))

Nasza funkcja przyjmuje dwa argumenty tj. playlista i baza. Pamiętając jak wygląda struktura naszego słownika playlist_base, wiemy, że wartości to lista składająca się odwołań do piosenek znajdujących się w base_dict. Każde to odwołanie odnosi się do kolejnej listy, gdzie przechowujemy informacje o konkretnej piosence. Mamy więc tutaj zagnieżdżone listy. Teraz rozważmy trzy sytuacje. W pierwszym przypadku nie zrobimy kopii playlisty (czyli playlista=[*playlista]). Co się wtedy stanie? Dalej w kodzie używamy metody random.shuffle(playlista). Jeśli nie zrobimy kopii, to wymiesza nam kolejność elementów w playliście, którą przekazaliśmy jako argument funkcji. Tego nie chcemy, ponieważ oryginalna playlista powinna zachować kolejność piosenek taką, jaką podał użytkownik. playlista=[*playlista] utworzy płytką kopię. Oczywiście możesz użyć dowolnego sposobu kopiowania z tych podanych w artykule. Nasza playlista wygląda teraz mniej więcej tak [[lista],[lista],[lista],[lista],[lista],[lista]]. Każda z tych zagnieżdżonych list jest współdzielona z base_dict. Dlatego gdy nasza funkcja zmieni któryś z elementów z list wewnątrz playlisty, te listy zmienią się także dla base_dict. Możesz użyć deepcopy, jeśli nie chcesz, żeby tak się stało. Z drugiej strony chcemy zaktualizować dane o liczbie odsłuchań zarówno dla playlisty jak i dla naszej bazy z piosenkami. Najpierw musimy zmienić typ danych ze string na integer, ponieważ wszystko co odczytujemy z pliku tekstowego, jest domyślnie stringiem, także liczby song[3]=int(song[3])+1. Liczba odsłuchań zwiększy się o 1 zarówno dla playlista jak i dla base_dict/baza. Następnie konwertujemy song[3] z powrotem na string. Jest to koniecznie, ponieważ, aby zaktualizować nasz plik tekstowy, używamy metody write(), która przyjmuje tylko stringi. Każda pojedyncza zagnieżdżona lista wewnątrz base_dict zostanie połączona w string przy użyciu .join(): values = [';'.join(item) for item in baza.values()]. W values przechowujemy listę ze stringów(poszczególnych linijek). Tę listę łączymy kolejnym .join(), po to, aby każda lista dotycząca danej piosenki została zapisana w nowej linii. Przy metodzie write() trzeba bardzo uważać, ponieważ jeśli zrobi się błąd w kodzie, to może zniknąć całą zawartość nadpisywanego pliku. Podczas testowaniu kodu proponuję pracować najpierw na kopii pliku tekstowego.
Teraz możemy sprawdzić naszą funkcję:

print(base_dict) #przed odsłuchaniem
list_name=input("Podaj nazwę playlisty: ")
play_random(playlist_base.get(list_name, "Brak takiej playlisty"),base_dict)
print(base_dict) #po odsłuchaniu

Do funkcji play_random wkładamy dwa argumenty: playlistę oraz bazę. Playlistę pobieramy z playlist_base. Do pobierania wartości klucza, użyłam metody get, ponieważ zabezpieczy nas przed KeyError, jeśli użytkownik w inpucie poda złą nazwę. Zamiast erroru pojawi się domyślnie ustawiony komunikat „Brak takiej playlisty”.