Como funcionam as listas de Python?

Nas primeiras vezes em que ouvi falar das listas de Python, por alguma razão eu acreditava que elas eram implementadas como listas encadeadas. Porém, se assim fosse, teríamos um problema: o custo para acessar um elemento dependeria diretamente da posição em que ele estivesse dentro da lista — isto é, um acesso ao milésimo elemento de uma lista seria mais custoso do que um acesso ao primeiro elemento. Isso porque as listas encadeadas são estruturas de acesso sequencial, onde para acessar o elemento da posição i, é preciso antes percorrer os i-1 elementos que o precedem.

Vamos executar alguns testes para ver se o custo para acesso a um elemento está relacionado à posição do elemento na lista. Nos testes, vamos gerar listas de um milhão de elementos e acessar elementos de diferentes posições em cada teste. Para isso, vamos usar o módulo timeit:


$ python -m timeit -s "lista = range(1000000)" "lista[0] = 0"
10000000 loops, best of 3: 0.0276 usec per loop
$ python -m timeit -s "lista = range(1000000)" "lista[10] = 0"
10000000 loops, best of 3: 0.0276 usec per loop
$ python -m timeit -s "lista = range(1000000)" "lista[1000] = 0"
10000000 loops, best of 3: 0.0276 usec per loop
$ python -m timeit -s "lista = range(1000000)" "lista[100000] = 0"
10000000 loops, best of 3: 0.0276 usec per loop

Os experimentos nos mostram que obtivemos tempos de acesso constantes para as diversas posições da lista. Chamamos de acesso aleatório essa capacidade de termos tempos de acesso iguais para todos os elementos contidos na sequência, independentemente das suas posições. Em estruturas de acesso aleatório, o custo para acessar o primeiro, o i-ésimo ou o último elemento deverá ser o mesmo, diferentemente de estruturas de acesso sequencial.

Para que conseguir o desempenho apresentado nos testes acima, é bem provável que as listas de Python não sejam implementadas sobre listas encadeadas, mas sim sobre arrays, que são estruturas de acesso aleatório. Para confirmar tal hipótese, vamos verificar o código-fonte do CPython. O arquivo listobject.h contém a definição da estrutura de dados que representa uma lista em Python:

typedef struct {
    PyObject_VAR_HEAD
    /* Vector of pointers to list elements. list[0] is ob_item[0], etc. */
    PyObject **ob_item;
    Py_ssize_t allocated;
} PyListObject;

Na estrutura acima, dá pra ver que a lista de Python é realmente implementada sobre um array (campo ob_item da estrutura) e, para entendermos melhor porque os arrays são eficientes, vamos dar uma olhada mais a fundo na próxima seção.

Como funcionam os Arrays?

Um array é uma área contígua de memória utilizada para armazenar vários elementos de um mesmo tipo em uma única variável. Exemplo de array:

array

Arrays são bons porque são simples e porque o acesso a qualquer elemento dentro dele é muito rápido, pois é feito através de índices, que são utilizados como deslocamentos com relação ao endereço inicial do array na memória. Podemos dizer que os arrays fornecem acesso aleatório, pois o tempo e a complexidade para acessar qualquer elemento independem do tamanho do array e da posição do elemento desejado. Vamos ver agora o porquê disso.

Imagine que o array acima esteja na memória do computador, a partir do endereço 2048, como mostra a imagem abaixo.

array2

O endereço na memória do elemento contido em uma posição i do array pode ser calculado de uma forma bem simples:

endereço base + i * tamanho em bytes do tipo

O acesso a uma posição de um array nada mais é do que um acesso ao endereço de memória calculado através da expressão acima. Desse modo, o tempo de acesso a qualquer um dos elementos de um array é igual, pois tudo que é preciso para isso é calcular o endereço de memória do elemento através de uma expressãozinha aritmética e fazer um acesso ao endereço de memória calculado. Ou seja, um array tem tempo de acesso constante — O(1) — para acesso a um elemento qualquer (diferentemente das listas encadeadas, que tem tempo de acesso linear – O(n)).

Essa característica faz com que o array seja uma estrutura mais atrativa devido ao tempo de acesso a um elemento aleatório quando comparado a estruturas como as listas encadeadas, nas quais, para acessar um elemento k, é necessário antes passar por todos os k-1 elementos que o precedem. Essa diferença é bem significativa quando estivermos lidando com listas ou arrays com alguns milhares ou milhões de elementos.

Entretanto, se for preciso redimensionar um array, pode ser necessário mover todo o conteúdo do mesmo para outra área que contenha espaço contíguo suficiente para acomodar o array antigo, além do espaço extra que precisamos. Já com uma lista encadeada, esse problema não existe, pois os elementos não precisam estar em posições contíguas de memória.

O que acontece quando fazemos um append numa lista?

As listas de Python são estruturas que crescem dinamicamente, alocando mais espaço para novos elementos sempre que for necessário. Isso poderia implicar em uma perda de desempenho ao fazer a inserção de novos elementos na lista, pois seria necessário realocar o array subjacente para que os novos elementos caibam dentro dele.

A cada vez em que tentamos fazer a inserção de um novo elemento em uma lista cheia, é preciso:

  1. Alocar um novo espaço contíguo que seja capaz de acomodar nosso array com os novos elementos adicionais.
  2. Copiar todos os elementos do array para a nova área de memória.
  3. Liberar o espaço anteriormente ocupado pelo array, para que o mesmo possa ser reutilizado.

Porém, de acordo com o código fonte do CPython, sempre que for necessário realocar o array subjacente, é incluído um pouco de espaço extra para evitar a realocação desse array nas próximas inserções. O espaço extra a ser alocado é proporcional ao tamanho atual da lista e é calculado assim:

new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6);

De acordo com os comentários no código do CPython, essa sobre-alocação vale a pena e permite que o tempo para alocação acabe sendo amortizado quando ocorre uma grande sequência de appends na lista.

Enfim…

As listas são as estruturas padrão para representação de sequências de dados em Python e é importante que saibamos o que está acontecendo quando trabalhamos com elas. Para conhecer as listas mais a fundo, você pode ler o código fonte do CPython.

Vários outros posts aqui no blog já trataram sobre a utilização de listas; confira alguns:

Anúncios

Brincando com Listas

Criando uma lista de números em sequência:

# python 2:
lista = range(100)
# python 3:
lista = list(range(100))

Criando uma lista com list comprehensions:

lista = [x*2 for x in range(100)]

Percorrendo uma lista com for in:

for x in lista:
    # faça algo com x

Percorrendo uma lista, obtendo os valores e seus índices:

for indice, valor in enumerate(lista):
    print "lista[%d] = %d" % (indice, valor)

Percorrendo um pedaço de uma lista usando slicing:

for x in lista[40:60]:
    # faça algo com x

Percorrendo uma lista de trás pra frente definindo o passo do slicing como -1:

for x in lista[::-1]:
    # faça algo com x

Ou:

for x in reversed(lista):
    # faça algo com x

Percorrendo uma lista ordenada:

for x in sorted(lista):
    # faça algo com x

Acessando o último elemento de uma lista com o índice -1:

print lista[-1]

Copiando uma referência para uma lista:

>>> nova_ref = lista
>>> nova_ref is lista
True

Copiando de verdade uma lista:

>>> nova_lista = lista[:]
>>> nova_lista is lista
False

Ou, usando o módulo copy:

>>> import copy
>>> nova_lista = copy.copy(lista)
>>> nova_lista is lista
False

Ou caso lista contivesse listas aninhadas e quiséssemos fazer com que estas também fossem completamente copiadas, e não somente referenciadas, usaríamos a função deepcopy():

>>> nova_lista = copy.deepcopy(lista)
>>> nova_lista is lista
False

Embaralhando os elementos de uma lista (in-place):

>>> import random
>>> random.shuffle(lista)  # altera a própria lista

Obtendo uma amostra aleatória (de 10 elementos) de uma lista:

>>> print random.sample(lista, 10)
[729, 9025, 2401, 8100, 5776, 784, 1444, 484, 6241, 7396]

Obtendo um elemento aleatório de uma lista:

>>> random.choice(lista)
7744

Gerando uma lista com 10 números aleatórios, com valores entre 0 e 99:

>>> lista_aleatoria = random.sample(range(0, 100), 10)

Obtendo o maior elemento de uma lista:

>>> lista = range(0, 10)
>>> print max(lista)
9

O menor:

>>> print min(lista)
0

Pegando somente os elementos de índice par:

>>> print lista[::2]
[0, 2, 4, 6, 8]

Os de índice ímpar:

>>> print lista[1::2]
[1, 3, 5, 7, 9]

Somando todos os elementos de uma lista:

>>> print sum([1, 2, 3, 4])
10

Juntando duas listas, formando pares de elementos:

>>> lista = zip(range(0, 5), range(5, 10))
>>> print lista
[(0, 5), (1, 6), (2, 7), (3, 8), (4, 9)]

Separando os elementos de uma lista de forma intercalada:

>>> lista = range(0, 10)
>>> intercaladas = lista[::2], lista[1::2]
>>> print intercaladas
([0, 2, 4, 6, 8], [1, 3, 5, 7, 9])

Transformando uma lista de strings em uma string CSV:

>>> lista = ["ola", "mundo", "aqui", "estamos"]
>>> csv_values = ','.join(lista)
>>> print csv_values
ola,mundo,aqui,estamos

Aplicando uma função (neste caso, anônima) a todos elementos de uma lista:

>>> lista = range(1, 11)
>>> print map(lambda x: x*-1, lista)
[-1, -2, -3, -4, -5, -6, -7, -8, -9, -10]

Filtrando os elementos de uma lista de acordo com um critério:

>>> def criterio(x): return x >= 0
>>> print range(-5, 5)
[-5, -4, -3, -2, -1, 0, 1, 2, 3, 4]
>>> print filter(criterio, range(-5, 5))
[0, 1, 2, 3, 4]

Retirando os elementos cujo valor é zero (ou melhor, cujo valor é avaliado como False):

>>> print filter(None, range(-2, 2))
[-2, -1, 1]

E você, tem alguma dica que poderia ser inserida aqui no post? Poste sua sugestão nos comentários.

Conjuntos em Python

Hoje percebi que o blog já tratou sobre listas, dicionários, tuplas; mas até agora não escrevi texto algum sobre os conjuntos (também conhecidos por sets).

Sets: o que são?

Sets (ou, como iremos chamar daqui para a frente, conjuntos) são estruturas disponíveis como builtins do Python, utilizadas para representar coleções desordenadas de elementos únicos. É importante sempre lembrar dos conjuntos por suas duas principais características:

  1. Os elementos não são armazenados em uma ordem específica e confiável;
  2. Conjuntos não contém elementos repetidos.

A característica número 1 é importante, porque o desenvolvedor jamais deve confiar na ordenação de um conjunto, visto que a ordem em que os elementos são mantidos nos conjuntos varia de implementação para implementação do interpretador Python. Não é a toa que conjuntos não suportam indexação nem fatiamento (slicing). Bom, chega de papo e vamos ver alguns exemplos de conjuntos.

Mãos na massa

Vamos começar definindo um conjunto, usando a sintaxe para literais de conjuntos (introduzida há pouco tempo nas versões 3.1 e 2.7):

>>> s = {1, 2, 3, 4}
>>> print s
set([1, 2, 3, 4])

Existem várias operações disponíveis nos conjuntos através de métodos, como as operações mais conhecidas de teoria dos conjuntos, como união, interseção e diferença.

União

Imagem da Wikipedia

A U B (Crédito da imagem: Wikipedia)

>>> a = {1, 2, 3, 4}
>>> b = {3, 4, 5, 6}
>>> print a.union(b)
set([1, 2, 3, 4, 5, 6])

Interseção

Imagem da Wikipedia

A ∩ B (Crédito da imagem: Wikipedia)

>>> print a.intersection(b)
set([3, 4])

Essa operação é muito útil quando precisamos descobrir elementos que duas listas possuem em comum:

>>> l1 = [1, 2, 3]
>>> l2 = [2, 4, 3]
>>> print set(l1).intersection(l2)
set([2, 3])

Perceba que convertemos l1 para conjunto para podermos usar o método intersection; já l2 não precisou ser convertida, pois esses métodos exigem que apenas o primeiro argumento seja um conjunto. Poderíamos obter o resultado da interseção como uma lista também:

>>> l3 = list(set(l1).intersection(l2))
>>> print l3
[2, 3]

O método intersection não modifica os conjuntos recebidos como parâmetro. Se quisermos que o resultado da interseção seja gravado como novo valor do primeiro conjunto, ao invés de retornar o novo conjunto como resultado, podemos usar o método intersection_update:

>>> a.intersection_update(b)
>>> print a
set([2, 3])

Diferença

Imagem da Wikipedia

B \ A (Crédito da imagem: Wikipedia)

A diferença entre dois conjuntos A e B retorna somente os elementos de A que não estão em B, ou seja, retira de A todos os elementos comuns a ambos os conjuntos:

>>> a = {1, 2, 3, 4}
>>> b = {3, 4, 5, 6}
>>> print a.difference(b)
set([1, 2])
>>> print b.difference(a)
set([5, 6])

Observe que a.difference(b) é o mesmo que a \ b e que b.difference(a) é o mesmo que b \ a.

Assim como o método anterior, difference também não altera o conjunto sobre o qual é chamado. Para alterá-lo, é necessário usar o método difference_update().

Diferença simétrica

Diferença simétrica é uma operação sobre os dois conjuntos, que retorna todos os elementos (de ambos os conjuntos a e b) que pertencem a somente um dos conjuntos.

>>> a = {1, 2, 3, 4}
>>> b = {3, 4, 5, 6}
>>> print a.symmetric_difference(b)
set([1, 2, 5, 6])

Diferença Simétrica

A △ B (Crédito da imagem: Wikipedia)

Pertinência

Além das operações tradicionais de união, interseção e diferença, também temos operações de verificação de pertinência. A seguir veremos algumas.

Para verificar se um determinado elemento pertence a um conjunto, podemos usar o já conhecido operador de pertinência in:

>>> a = {1, 2, 3, 4}
>>> b = {3, 4, 5, 6}
>>> 1 in a
True
>>> 5 in a
False

Também podemos verificar se um conjunto é um subconjunto de outro:

>>> a = {1, 2, 3, 4}
>>> c = {1, 2}
>>> c.issubset(a)
True
>>> a.issubset(c)
False

Além disso, podemos verificar se um conjunto é superconjunto de outro:

>>> a.issuperset(c)
True

Outra relação importante que podemos checar é a disjunção entre dois conjuntos. Dois conjuntos são disjuntos se tiverem interseção nula. Exemplo:

>>> c = {1, 2}
>>> d = {3, 4}
>>> c.isdisjoint(d)
True

Removendo elementos duplicados de uma sequência

Por definição, um conjunto é uma coleção de valores únicos (e desordenados). Assim sendo, se passarmos ao construtor de conjuntos uma lista com valores repetidos, esses valores serão eliminados de forma a permanecer apenas um deles. Exemplo:

>>> lista = [1, 1, 2, 3, 5, 8]
>>> conjunto = set(lista)
>>> print conjunto
set([8, 1, 2, 3, 5])

Ou, se quisermos ter de volta uma lista:

>>> lista = list(set(lista))

ATENÇÃO: a operação acima pode (e, com grandes chances, irá) bagunçar a lista. Ou seja, a ordem original irá se perder.

>>> print lista
[8, 1, 2, 3, 5]

Leia mais

Variáveis e valores

Ao criarmos uma variável em nosso código, na realidade estamos é criando um objeto e uma referência para esse objeto. Por exemplo, considere o código abaixo:

>>> x  = 100

O que fizemos foi “criar uma variável” com o valor 100 dentro dela, certo? Mais ou menos…  O que acontece, na realidade, é que 100 é um objeto do tipo inteiro que é alocado em algum lugar na memória. E x nada mais é do que uma referência (um ponteiro) para aquele objeto.

O código abaixo cria uma lista de 3 elementos e faz com que a variável x a referencie.

>>> x = [1, 2, 3]

Ao executar o código seguinte, o que irá acontecer? Errou quem acha que estamos fazendo uma cópia da lista [1, 2, 3] em y.

>>> y = x

Antes de explicar, vejamos seu conteúdo.

>>> print y
[1, 2, 3]

Ué, não é cópia mesmo? Vejamos:

>>> x.append(10)
>>> print y
[1, 2, 3, 10]

O que aconteceu? Adicionamos um valor ao final da “lista x” e ele apareceu milagrosamente na “lista y”? Não. Quando executamos y = x, fizemos com que a variável y passasse a apontar para o mesmo objeto apontado por x (a lista [1,2,3]). Assim, ao executarmos o append(10) sobre o objeto apontado por x, estamos modificando o objeto apontado por y também (que na realidade é o mesmo!).

Ou seja, ao executar y = x não fizemos uma cópia da lista, mas sim da referência a tal lista.

E como fazemos para copiar a lista? Bem, em Python existe um tipo de operação chamado de slicing, que funciona sobre objetos como as listas e que gera um novo objeto do mesmo tipo, com os elementos que estão contidos entre as posições especificadas pelos índices. Por exemplo:

>>> x = [1, 2, 3, 4, 5, 6, 7]
>>> print x[1:5]
[2, 3, 4, 5]

Essa operação gerou uma nova lista, contendo os valores das posições 1 até 5-1 (4). Se omitirmos os índices, estaremos nos referindo à lista como um todo, do início ao fim. Assim:

>>> print x[:]
[1, 2, 3, 4, 5, 6, 7]
>>> y = x[:]
>>> x.append(10)
>>> print x
[1, 2, 3, 4, 5, 6, 7, 10]
>>> print y
[1, 2, 3, 4, 5, 6, 7]

Como podemos ver no código acima, ao executarmos y = x[:], estamos gerando uma nova lista, com conteúdo idêntico à lista apontada por x, e fazendo com que y faça referência a ela. Então, para que façamos uma cópia de uma lista apontada pela variável x em uma variável y, fazemos o seguinte:

>>> y = x[:]

Então, lembre-se, para fazer uma cópia de uma lista inteira, podemos usar o “truque” do slicing e lembre-se também de que uma simples atribuição de uma variável para outra não copia a lista, mas sim a referência.

Porém, nem tudo são flores… Se a lista em questão contiver referências a outros objetos, ao invés dos objetos em si, a “cópia” feita será uma cópia rasa, pois os objetos referenciados dentro da lista não serão copiados. O contrário disso seria a cópia profunda (deep copy), onde os objetos referenciados na lista é que serão copiados, ao invés de apenas as suas referências.

Leia mais sobre as listas: http://docs.python.org/tutorial/datastructures.html

Número de argumentos variável em funções

Para quem não sabe, Python possui uma sintaxe que permite que definamos uma função com um número indefinido de argumentos. Isso pode ser feito através do * (asterisco). Por exemplo:

def func(x, *args):
    print x
    print args
func(1, 2, 3, 4, 5)

Execute o código acima, e verá a seguinte saída:

1
(2, 3, 4, 5)

Ou seja, o primeiro argumento fornecido pelo usuário (o inteiro 1) é atribuído ao primeiro parâmetro da função. Os argumentos restantes (os inteiros 2, 3, 4 e 5) são empacotados em uma tupla e passados ao parâmetro *args. Desse modo, os elementos de args podem ser acessados como os elementos de uma tupla qualquer, seja por índice, ou realizando uma travessia com for in. Por exemplo, uma função que realiza a soma de todos os argumentos recebidos:

def soma(*args):
    result = 0
    for elem in args:
        result += elem
    return result

soma(1, 2, 3, 4, 5)

A saída da execução do código acima será a soma dos valores 1, 2, 3, 4 e 5, que é 15.

Também podemos usar o * em frente ao nome de sequências (como tuplas, listas,) para fazer com que os valores que compõem tais sequências sejam desmembrados e passados como argumentos para funções. Considere e função func() abaixo:

def func(a,b,c,d):
    print a,b

Ela possui 4 parâmetros (aqui representados pelas variáveis a, b, c, d) e imprime os valores de a e de b. Certo. Imagine agora que você possui uma lista com 4 valores, que deseja passar para a função func(). Poderia fazer o seguinte:

l = [1, 2, 3, 4]
func(l[0], l[1], l[2], l[3])

Correto? Sim. Mas dá pra fazer melhor. E se eu quisesse passar a lista toda para a função, sem precisar acessar os elementos individuais.

l = [1, 2, 3, 4]
func(l)

Errado! Afinal, a função espera 4 valores e estamos passando somente um, uma lista. É aí que podemos usar o *. Veja:

l = [1, 2, 3, 4]
func(*l)
Legal, não? Em um próximo post, vou falar de dicionários com argumentos nomeados.

A função enumerate()

Como percorremos os elementos de uma lista? Podemos utilizar a construção for in. Para cada elemento da lista, faça algo com tal elemento.

l = ['hello', 'world', 'hi', 'earth']
for word in l:
    print word

Outra forma de fazermos uma travessia em uma lista é através do acesso via índices.

l = ['hello', 'world', 'hi', 'earth']
for i in range(0, len(l)):
    print l[i]

O que é semelhante a:

l = ['hello', 'world', 'hi', 'earth']
i = 0
while i < len(l):
    print l[i]
    i = i + 1

Qual é a diferença básica entre os dois últimos códigos (que utilizam for i in range e while) para o primeiro trecho de código apresentado? É o acesso ao índice, à posição do elemento na lista. Utilizando a estrutura for elem in, não sabemos qual é a posição do item atual. Isso poderia ser necessário, por exemplo, se precisarmos escrever uma função que retorne uma lista contendo os índices das palavras que começam pelo caractere ‘a‘.

Para contornar esse problema, existe a função enumerate(). Essa função pode receber como entrada uma lista e irá retornar um objeto do tipo enumerate, que poderá ser percorrido pelo for. Vejamos um exemplo simples:

l = ['hello', 'world', 'hi', 'earth']
for i, word in enumerate(l):
    print i, word

Isso irá gerar a seguinte saída:

0 hello
1 world
2 hi
3 earth

E se quisermos escrever a função que retorna uma lista contendo as posições das palavras de uma lista que começam com a letra ‘a’?

def posicoesQueIniciamCom(lista, letra='a'):
    result = []
    for i, palavra in enumerate(lista):
        if palavra.startswith(letra):
            result.append(i)
    return result

nomes = ['abc', 'hua', 'aaa', 'asdfg', 'bnm']
print posicoesQueIniciamCom(nomes)

O resultado da execução do código acima será:

[0, 2, 3]

Assim, sempre que precisarmos dos índices dos elementos das listas (ou outros iteráveis) que estivermos percorrendo, podemos utilizar a função enumerate() para construir uma estrutura composta por pares índice-elemento.

filter() – filtrando elementos de uma lista

Imagine uma situação na qual você tenha uma lista composta por uma grande quantidade de números, e deseja utilizar esses números para a realização de um cálculo. Porém, alguns desses valores devem ser descartados, por não se enquadrarem na faixa de valores que você pode utilizar nos cálculos. Como retirar os elementos que não cumprem os requisitos?

A solução mais óbvia seria percorrer a lista removendo os elementos que não cumpram o requisito, ou então, criar uma nova lista somente com os elementos que cumprem o requisito. Considere que o requisito seja: somente números positivos podem compor a lista. Vamos considerar a lista lista como exemplo:

lista = [8, 9, -1, -3, 3, -5, -4, 5, -4, 2, 5, 91, -11, 5, 10, 93, -75]
lista_valida = []
for elem in lista:
    if elem > 0:
        lista_valida.append(elem)
print lista_valida

O código acima cria uma nova lista (lista_valida) somente com os elementos positivos da lista lista. Funciona, certo? Sim, mas Python oferece uma forma mais prática e elegante para fazermos isso, a função filter(). Como o nome já diz, ela filtra uma lista. Ou seja, só “passam” os elementos que cumprirem os requisitos. A função filter() recebe dois parâmetros: uma função para validação de cada elemento, e uma sequência de valores (como uma lista, por exemplo). Vamos implementar o exemplo anterior utilizando filter().

lista = [8, 9, -1, -3, 3, -5, -4, 5, -4, 2, 5, 91, -11, 5, 56, 93, 13, 15, -75, 98]
def maior_que_zero(num):
    if num > 0:
        return True
 else:
        return False

lista_valida = filter(maior_que_zero, lista)
print lista_valida

Criamos uma função chamada maior_que_zero() que retorna True caso o valor recebido como parâmetro seja maior que 0 e False, caso contrário. Essa função vai ser chamada para cada elemento da lista lista, quando executarmos a função filter(), e essa, por sua vez, só vai incluir na lista nova (a ser retornada) os valores para os quais a execução de maior_que_zero() resultaram em True.

Vamos a outro exemplo: temos uma lista composta por strings e queremos obter uma lista contendo somente as strings que começam pela letra ‘a’ ou pela letra ‘e’.

palavras = ['teste', 'assistente', 'estou', 'aqui', 'onde', 'ouro', 'adeus']
def f(p):
    return p.startswith('a') or p.startswith('e')

print filter(f, palavras)

No código acima, aplicamos a função f() a cada elemento de palavras para filtrar essa lista. Ao final, filter() retorna uma lista contendo somente os elementos para os quais a expressão p.startswith(‘a’) or p.startswith(‘e’) retorna True (que são os elementos que iniciam por ‘a’ ou por ‘e’).

Esse é mais um dos tantos recursos interessantes que Python nos oferece e que podemos utilizar em nosso benefício. Teste os códigos acima e faça seus próprios testes.