Listas

E mais: tuplas

Até aqui aprendemos vários tipos de dados: int, float, bool, str. O que todos esses tipos tem em comum é que eles são simples, básicos. Hoje vamos aprender tipos de dados compostos: tuplas e listas. Chamamos tuplas e listas de tipos compostos porque teoricamente elas não são um tipo sozinhas: elas são sempre tuplas/listas de alguma coisa (listas de números, listas de listas de strings, etc).

1. Tuplas

Tuplas são uma generalização de duplas/pares; podemos ter triplas, quádruplas, etc. Representamos tuplas por parênteses, com cada elemento da tupla separado por vírgulas:

empty_tuple = ()
interval = (0, 1)

Acessamos valores de tuplas de duas formas. Uma delas nós já conhecemos de outra aula: quando retornamos múltiplos resultados de uma função é como se estivéssemos retornando uma tupla, e portanto podemos usar a mesma sintaxe de assignment para desestruturar (‘desmontar’) a tupla.

interval = (0, 1)
beg, end = interval

A outra forma é por meio de indexação, especificando diretamente o índice do elemento que queremos. Como de praxe, a contagem do índice começa do zero (e portanto o índice do último elemento de uma tupla de \(n\) elementos é \(n-1\)):

full_name = ("Guido", "van Rossum")
first_name = full_name[0]
surname = full_name[1]

Como se vê, para fazer uma indexação nós colocamos colchetes depois do que queremos indexar, com o número do índice dentro dos colchetes.

Nada impede que usemos uma variável para indexar, mas é preciso que o valor dela seja um inteiro:

i = 2
student_info = ("Jack Black", "5906-94", 67.9)
grade = student_info[i]

Uma grande utilidade de tuplas é em fazer assignments múltiplos. Quando queremos trocar o valor de duas variáveis (um swap) sem usar tuplas, precisamos criar uma terceira variável não relacionada:

a = 0
b = 1
# swap!
tmp = b
b = a
a = tmp

Com tuplas e assignment múltiplo, podemos fazer simplesmente:

a = 0
b = 1
# swap!
(b, a) = a, b

Isso funciona pois do lado direito do = ainda temos os valores antigos de a e b. Se tentássemos separar os assignments perderíamos um dos valores:

a = 0
b = 1
# swap!
b = a
a = b # aqui a == b == 0

2. Listas

O tipo de dados lista também é intuitivo, sendo uma sequência de elementos acessível por índice (indexável). Listas em geral contém um tipo só (listas de inteiros, listas de tuplas de string e números, etc).

empty_list = []
finite_fibonacci = [0, 1, 1, 2, 3, 5, 8]
len(finite_fibonacci) # calcula o ‘comprimento’ da lista, seu número
                      # de elementos
finite_fibonacci[4] # == 3

Como podemos ver, uma lista é declarada por um par de colchetes, e seus elementos vão dentro dos colchetes, separados por vírgulas. Não há confusão possível com a sintaxe de indexação, veja:

grades = [8.8, 7.5, 0, 9.4]
print(grades[2]) # alguém faltou à prova…
print(["hey", "you"][1])

O grande diferencial de listas é que elas são mutáveis (assim como variáveis), e extensíveis (seu tamanho pode crescer ou diminuir).

grades = [8.8, 7.5, 0, 9.4]
grades[2] = 6.7 # substituindo com a nota da segunda chamada
print(grades)

grades = grades + [5.5, 7.1, 8.3] # adicionando notas de outra turma
                                  # da mesma disciplina
print(grades)

Outro ponto interessante de listas é que podemos iterar sobre elas:

sum_total = 0
grades = [8.8, 7.5, 0, 9.4]
for i in range(len(grades)):
    sum_total = sum_total + grades[i]

average = sum_total / len(grades)

Mas na verdade, a melhor forma de iterar sobre uma lista é sem usar range:

sum_total = 0
grades = [8.8, 7.5, 0, 9.4]
for grade in grades:
    sum_total = sum_total + grade

average = sum_total / len(grades)

Desta forma não precisamos de usar os índices desnecessariamente, e portanto é impossível de errá-los, começando a iteração do índice errado, ou acabando antes do último elemento, ou tentando acessar um elemento que não existe (tente fazer ["hello", "there"][2]).

2.1. Exercício: variância

Crie uma função chamada variance que recebe uma lista de números e calcula a variância populacional deles, dada por \(\sigma^2\).

\[\sigma^2 = \frac{1}{N}\sum_{i=1}^{N} (x_i - \mu)^2 \qquad \mu = \frac{1}{N}\sum_{i=1}^{N} x_i\]

3. Operadores e funções de listas

Como vimos em exemplos anteriores, podemos usar a função len para determinar o tamanho ou comprimento de uma lista (seu número de elementos). Também podemos usar o operador de adição (+) para fazer uma lista maior. Além disso, o operador in que usamos para fazer um loop sob uma lista (for element in my_list) também pode ser usado sozinho (sem o for) para checar se um valor está presente na lista, retornando o valor booleano (True ou False) correspondente:

if "needle" in ["hay", "more hay", "needle", "even more hay"]:
  print("Found it!")

print(3.141592 in [3, 3.1, 3.14, 10])

Nem len nem in são operadores exclusivos de listas (mais para a frente veremos mais usos para eles), mas existem uma série de funções que podem ser aplicadas somente à listas:

fila = ["Michael", "Dwight", "Angela"]
list.pop(fila) # remove e retorna o último elemento da lista
list.sort(fila) # ordena os elementos da lista
list.append(fila, "Jim") # adiciona um novo elemento ao final da lista

Como se vê, essas funções todas são começam com list., por conta de sua exclusividade para listas. Para economizar digitação, Python nos permite omitir o prefixo list da seguinte forma:

fila = ["Michael", "Dwight", "Angela"]
fila.pop() # remove e retorna o último elemento da lista
fila.sort() # ordena os elementos da lista
fila.append("Jim") # adiciona um novo elemento ao final da lista

Isso só funciona se o que vier antes do ponto de fato for uma lista, e nesse caso fila.append("Jim") e list.append(fila, "Jim") são equivalentes (se fila não for uma lista, teste e veja o que acontece em cada caso).

Ao rodar esse pedaço de código, o que você espera que seja o valor final de lista fila? Até aqui, se nós fizermos uma operação como x + 1 e não fizermos o reassignment (como em x = x + 1), o valor calculado é perdido, descartado. Mas ao averiguarmos o valor de fila depois das operações acima, ele mudou.

3.1. Exercício: produto cartesiano

Crie uma função chamada cartesian_product que recebe duas listas e produz uma nova lista que seja o produto cartesiano entre elas. Para fazer o produto em si, utilize tuplas; de modo que se \(n\) é membro da primeira lista e \(m\) é membro da segunda lista, então \((m, n)\) é membro da lista-resultado.

Por exemplo, uma implementação correta de cartesian_product teria o seguinte comportamento (mas a ordem dos elementos da lista pode ser diferente):

print(cartesian_product([1,2], [3,4])) # [(1,3), (1,4), (2,3), (2,4)]
print(cartesian_product([], [3,4])) # []
print(cartesian_product([1], [2, 3])) # [(1,2), (1,3)]

4. Funções puras e funções com efeito colateral

def append_pure(ls, elem):
    return ls + [elem]

def append_impure(ls, elem):
    ls.append(elem)
    return ls

# compare:
languages_i_love = []
print(append_pure(languages_i_love, "Spanish"))
print(languages_i_love)
print(append_impure(languages_i_love, "Python"))
print(languages_i_love)

Em Python e em outras linguagens de programação, algumas funções e comandos são puros, ao passo que outros são impuros ou tem efeitos colaterais. Calcular languages_i_love + ["Spanish"] não tem nenhum efeito colateral — nenhuma variável é modificada, não há nada printado na tela, etc. Por conta disso, se não fizermos um assignment languages_i_love = languages_i_love + ["Spanish"] o valor calculado será descartado. Funções como list.append tem efeito colateral; além de avaliarmos seus argumentos (uma lista e um valor a ser inserido nela), modifica-se a lista como ‘efeito’. print é impura, pois tem o efeito colateral de mostrar algo na tela.

Uma função pura, assim como uma função matemática, sempre tem o mesmo resultado para as mesmas entradas, e não modifica nem as entradas nem nenhuma outra variável. Se languages_i_love = [], podemos invocar append_pure(languages_i_love, "Spanish") 5 vezes e o resultado será sempre ["Spanish"], ao passo que languages_i_love continua sendo []. Para uma função impura, as mesmas entradas de uma função podem trazer resultados diferentes; basta invocar append_impure(languages_i_love, "Python") mais de uma vez e ver como o resultado é sempre diferente; isto acontece porque o valor de languages_i_love está sendo modificado a cada chamada da função. Teste você mesmo!

4.1. Descobrindo mais sobre uma função

Uma pergunta natural é como saber se uma função tem efeito colateral. Se você está usando uma função que não foi você que escreveu (por exemplo uma função que é built-in do Python como list.sort), quem escreveu a função deve ter informado se as propriedades da função, se ela tem efeitos colaterais, se modifica seus argumentos, etc. Podemos usar a função help no shell Python para ver a documentação de funções Python:

help(list.sort)

nos diz (entre outras coisas)

The sort is in-place (i.e. the list itself is modified) and stable (i.e. the order of two equal elements is maintained).

O que nos esclarece que list.sort modifica a lista. Se fizermos help(sorted), vemos que

Return a new list containing all items from the iterable in ascending order.

Ao dizer que retorna uma lista nova, entende-se que a anterior não é modificada, e portanto a função é pura.

Ao escrever nossas próprias funções nós podemos escolher se queremos que elas sejam puras ou não. Podemos informar isso e outras propriedades da função (além de explicações em geral) colocando uma string (chamada docstring) após a definição do nome da função e de seus argumentos, como no exemplo abaixo:

def append_pure(ls, elem):
    """Retorna um nova lista com um elemento adicionado à seu fim.

    Argumentos:
    ls -- lista
    elem -- elemento a ser adicionado ao final da lista
    """
    return ls + [elem]

help(append_pure)

Como se pode ver, help pode ser usado em funções que nós mesmos definimos, e o texto que adicionamos como docstring será também mostrado. help é prática de ser usada, mas também podemos encontrar essas e outras informações sobre uma função built-in de Python na documentação oficial (exemplo para sorted).

5. Mutabilidade e imutabilidade

Como mencionamos anteriormente, listas são mutáveis. Mas além de podermos modificar seus elementos, há uma outra consequência; veja a comparação abaixo com um tipo imutável (int).

n = 42
answer = n
n = n + 1
print(n, answer)
languages_i_love = []
languages_i_like = languages_i_love
languages_i_love.append("Python")
print(languages_i_love, languages_i_like)
languages_i_like = languages_i_like + ["Dothraki"]
# O que você acha que será o resultado?
print(languages_i_love, languages_i_like)
mutation-1.png
Figura 1: Estado inicial das variáveis após declaração inicial (visualização por Python Tutor)

Valores de tipos imutáveis não mudam, então quando fazemos alguma operação sobre eles (como somar 1) na verdade primeiro copiamos o valor e então o modificamos. Operações sobre valores imutáveis inclui o assignment, então ao fazermos answer = n, copia-se o valor de n daquele momento para answer, e quaiquer modificações futuras de n não se refletem de maneira alguma em answer.

mutation-2.png
Figura 2: Estado das variáveis após primeiras modificações (n = n + 1, languages_i_love.append("Python")) (visualização por Python Tutor)

No caso de valores de tipos mutáveis, ao fazermos languages_i_like = languages_i_love, não há cópia alguma; é como se fossem nomes diferentes para a mesma coisa. Quaisquer modificações da lista que é o objeto a que os nomes se referem se refletem em ambos. Contudo, ao fazer languages_i_like = languages_i_like + ["Dothraki"], como a operação + em listas é pura, retornando uma nova lista, o assignment quebra a ligação entre languages_i_love e languages_i_like: agora uma se refere a uma lista e a outra se refere a uma outra lista, e portanto modificações de uma lista não afetam a outra.

mutation-3.png
Figura 3: Estado das variáveis após languages_i_like = languages_i_like + ["Dothraki"] (visualização por Python Tutor)

6. Tuplas nomeadas

Tuplas são úteis para guardar informações, mas se tiverem muitos elementos seu uso começa a ficar desajeitado. É difícil de gravar a ordem correta dos elementos, e se trocarmos elementos de tipos iguais é difícil de perceber o erro.

# employee information is (name, age, title, department, paygrade)
employee1 = ("Meredith Grey", 30, "Surgical resident", "Surgery", 110000.00)
employee2 = ("Shonda Rhimes", 45, "Storyteller", "Well-Being", 200000.00)

Além disso, acessar os elementos também pode ser desajeitado, especialmente se não quisermos todos os valores de uma vez só.

drgrey = ("Meredith Grey", 30, "Surgical resident", "Surgery", 110000.00)
name, age, jobtitle, department, paygrade = drgrey # ok
jobtitle = drgrey[2] # não é muito informativo

Um grande problema é se decidirmos mudar o formato das tuplas. Digamos que queiramos incluir um novo elemento na tupla; se não colocarmos este elemento como último elemento da tupla, qualquer indexação anterior pode se tornar inválida:

# employee information now is (name, age, title, department, room_number, paygrade)
drgrey = ("Meredith Grey", 30, "Surgical resident", "Surgery", "404A", 110000.00)
drgrey[4] # era paygrade, agora é room_number

Da mesma forma, uma desestruturação da tupla antiga agora é inválida:

# ValueError: too many values to unpack (expected 5)
name, age, jobtitle, department, paygrade = drgrey

Tudo isso indica que ao fazermos uma pequena modificação do nosso código (modificar o número de elementos de uma tupla) temos uma série de outras modificações em cascata que precisamos fazer para não criar bugs (em todo uso da tupla precisamos ajustar índices e desestruturação).

Felizmente, há uma forma melhor de usar tuplas para armazenar dados estruturados: tuplas nomeadas. Cada tupla nomeada é na verdade um tipo diferente, que precisamos criar com a função namedtuple. A função retorna então um construtor, uma nova função que cria elementos do tipo da tupla nomeada que acabamos de criar, e cujos argumentos são os nomes que informamos como sendo os nomes da tupla nomeada. Veja o exemplo:

from collections import namedtuple
EmployeeRecord = namedtuple('EmployeeRecord', ['name', 'age', 'title', 'department', 'paygrade'])
drgrey = EmployeeRecord(name="Meredith Grey", age=30, title="Surgical resident", department="Surgery", paygrade=110000)
print(drgrey)

Nomear tuplas torna seu uso mais fácil, em parte porque temos uma sintaxe especial para acessar suas componentes: ao invés de drgrey[4] podemos fazer drgrey.paygrade. Outra facilidade é que se adicionarmos um novo elemento nomeado à tupla, não precisamos de modificar o código que usa os outros elementos, somente o que código que cria tuplas.

EmployeeRecord = namedtuple('EmployeeRecord', ['name', 'age', 'title', 'department', 'room_number', 'paygrade'])
drgrey = EmployeeRecord(name="Meredith Grey", age=30, title="Surgical resident", department="Surgery", paygrade=110000, room_number="404A")
print(drgrey.name, drgrey.paygrade, drgrey.room_number)

6.1. Exercício: calculando saldo de transações

O aplicativo Tricount (e vários outros concorrentes) gerencia contas em grupo. Cada pessoa adiciona gastos compartilhados entre pessoas no grupo (por exemplo: pessoas em uma viagem, em que uma pagou o hotel, a outra alugou um veículo, outra contratou um guia), e no final o aplicativo calcula o quanto cada uma deve pagar ou receber para que todos fiquem quites.

Neste exercício, implemente uma das funcionalidades principais do aplicativo: escreva a função calculate_balance, que dado o nome de uma pessoa e uma lista de gastos, retorna o saldo da pessoa em relação ao grupo. Se a pessoa pagou mais gastos do que ‘recebeu’ gastos de outras pessoas, seu saldo é positivo, e caso contrário seu saldo é negativo.

O nome das pessoas do grupo é sempre uma string, e o gasto será modelado como uma tupla nomeada:

Expense = namedtuple('Expense', ['name', 'payer', 'amount', 'beneficiaries'])
example_expenses = [Expense(name="Breakfast", payer="Sam", amount=15.90, beneficiaries=["Frodo", "Sam", "Pippin"]),
                    Expense(name="Breakfast (again)", payer="Pippin", amount=18.30, beneficiaries=["Frodo", "Sam", "Pippin"])]

em que name é um nome para o gasto (string), payer é o pagador (string), amount é o valor que foi pago (float), e beneficiaries é a lista dos nomes das pessoas por quem o gasto será dividido (lista de strings).

Se você implementou calculate_balance corretamente, calculate_balance("Frodo", example_expenses) deve retornar -11.4, já que Frodo não pagou nada para ninguém e recebeu dois cafés da manhã comprados por Sam e Pippin (nos valores totais de 15.90 e 18.30 cada, divididos por três pessoas isso significa que Frodo deveria ter pago 11.40 se cada um tivesse pagado a sua parte de uma vez).

Bruno Cuconato / 2024-08-07
TOP | HOME
Computação FGV/EPGE