Python: 10 truques legais com o operador *
Introdução
Você provavelmente já o conhece, mas de qualquer forma, o operador unário (*)
em Python (também conhecido como operador splat ) expande qualquer iterável
(exemplos: listas, tuplas, conjuntos ou geradores) em argumentos posicionais de
funções. Por exemplo:
def sum3(a, b, c):
    return a + b + c
    
my_list = [1, 2, 3]
sum3(*my_list) # = sum3(1, 2, 3) = 6
sum3(*range(3)) # = sum3(0, 1, 2) = 3
Também existe o operador **, ele expande 
mapeamentos (isto é, um
objeto que mapeia uma chaves a valaores, como um dict, um orderedDict ou até
um pandas.Series) em argumentos nomeados:
def sum3(a=0, b=0, c=0):
    return a + b + c
    
my_dict = {'a': 1, 'b': 2}
s = sum3(**my_dict) # = sum3(a=1, b=2) = 3, já que c usa o valor padrão (0)
Isso não parece muito útil? Bom, eles são mais do que você pensa! Aqui vou
mostrar algumas construções legais usando * e **.
Objetivos
- 
    Mostrar alguns truques usando *que podem ser úteis quando estamos lidando com listas, conjuntos, tuplas, dicionários e até strings;
- 
    Comparar seu desempenho com outras construções funcionais ou imperativas, medindo o tempo, olhando o bytecode e o olhando código-fonte do CPython. 
Não-objetivos
- 
    Substituir numpy,pandas,polarsou qualquer outra biblioteca que implementa estruturas de dados eficientes e suas operações;
- 
    Criar uma “boa prática”, “padrão de código”, dizer “faça isso porque é melhor”, etc. Encare com ceticismo tudo que você lê. 
1: Converter entre para listas, tuplas e conjuntos
Eu mencionei sobre o * sendo usado como expansão de parâmetros em funções,
mas ele também pode ser usado para expandir elementos na sintaxe de declaração
de listas, tuplas e conjuntos, então, você pode usar ele facilmente para fazer
conversões entre esses tipos:
# convertendo listas, tuplas e conjuntos
my_list = [1, 2, 3]
[*my_list]  # = [1, 2, 3]
{*my_list}  # = {1, 2, 3}
(*my_list,) # = (1, 2, 3), note a vírgula para garantir que é uma tupla!
Como isso se compara a usar um construtor?
Talvez você está pensando que você pode fazer a mesma coisa usando construtores de listas, tuplas e conjuntos, dessa forma:
# convertendo listas, tuplas e conjuntos
my_list = [1, 2, 3]
list(my_list)  # = [1, 2, 3]
set(my_list)   # = {1, 2, 3}
tuple(my_list) # = (1, 2, 3)
Sem problemas, use o que você preferir. Mas vamos analisar o que acontece
debaixo dos panos e comparar as duas abordagens no caso das listas. Podemos
conferir o bytecode usando o módulo dis:
from dis import dis
def f(x):
    _ = [*x]
    _ = list(x)
dis(f)
Este é o resultado (estou executando Python 3.12):
  2           2 BUILD_LIST               0
              4 LOAD_FAST                0 (x)
              6 LIST_EXTEND              1
              8 STORE_FAST               1 (_)
  3          10 LOAD_GLOBAL              1 (NULL + list)
             20 LOAD_FAST                0 (x)
             22 CALL                     1
             30 STORE_FAST               1 (_)
             32 RETURN_CONST             0 (None)
A primeira metade do bytecode se refere a [*x] e a segunda a list(x).
Na primeira metade ([*x]):
- cria uma lista vazia (BUILD_LIST 0) e manda para o topo da pilha de execução;
- carrega xno topo da pilha (LOAD_FAST 0);
- estende a lista vazia (LIST_EXTEND 1) com o iterávelx. Equivalente alist.extend(x);
- guarda o resultado na variável _(STORE_FAST 1).
Na segunda metade (list(x)):
- carrega o construtor listna pilha (LOAD_GLOBAL 1);
- carrega xna pilha (LOAD_FAST 0)
- chama o construtor listpassandoxcomo argumentolist(x)(CALL 1)
- guarda o resultado na variável _(STORE_FAST 1).
Nota:
RETURN_CONST 0faz a função devolverNone, já que esse é o comportamento padrão em Python para funções que não têm valor de retorno
Então, a principal diferença entre elas é que a primeira cria uma lista
vazia e depois a estende, enquanto que a segunda chama o construtor
list. Qual é melhor? Vamos ver o código fonte do CPython!
- 
    a implementação da instrução BUILD_LIST(aqui) chama a função_PyList_FromArraySteal(esta). Quando o tamanho da lista é 0, ela somente chamaPyList_New. Ela é, basicamente alocação de memória;
- 
    a implementação da instrução LIST_EXTEND(aqui) chama a função_PyListExtend(esta), que é só uma casca paralist_extend, que vai estender a lista vazia com os valores dex;
- 
    o inicializador de list(aqui) também vai chamarlist_extend, estendendo a lista vazia recém criada com valores dex.
Dessa forma, ambas fazem a mesma coisa!
2: Criar listas a partir de geradores
Como eu disse antes, * pode expandir qualquer iterável, então, você pode
usá-lo com qualquer gerador. Por exemplo, o clássico range:
[*range(5)] # = [0, 1, 2, 3, 4]
Mas você também pode usar ele com geradores mais interessantes. Eu recomendo fortemente que você leia sobre o módulo built-in itertools. Ele disponibiliza vários iteradores interessantes que você também pode usar aqui. Por exemplo:
from functools import permutations, pairwise
# Permutaç~oes
[*permutations([1, 2, 3])] # = [(1, 2, 3), (1, 3, 2), (2, 1, 3), (2, 3, 1), (3, 1, 2), (3, 2, 1)]
# Mapeia cada elemento com o seu próximo
[*pairwise([1, 2, 3, 4])]  # = [(1, 2), (2, 3), (3, 4)]
A mesma coisa pode ser feita para tuplas e conjuntos, da mesma forma que
antes! E, da mesma forma que antes, a gente pode usar os construtores list,
tuple e set no lugar da sintaxe correspondente (e eles vão fazer basicamente
a mesma coisa debaixo dos panos, da mesma forma que o truque anterior).
3: Concatenar listas, tuplas e conjuntos
Como * pode ser usado em declaração de listas, você também pode usar para
juntar uma ou mais delas:
list_1 = [1, 2, 3]
list_2 = [4, 5, 6]
[*list_1, *list_2] # = [1, 2, 3, 4, 5, 6]
Você ainda pode misturar listas (e outros iteráveis) e elementos soltos:
list_1 = [1, 2, 3]
list_2 = [4, 5, 6]
[*list_1, 11, *list_2, 12, 13, *range(3)] # = [1, 2, 3, 11, 4, 5, 6, 12, 13, 0, 1, 2]
Da mesma forma que os truques anteriores, isso pode usado para criar tuplas e conjuntos!
Olhando o bytecode de novo
Ok, mas o que acontece internamente? Vamos usar dis novamente aqui:
def f(a, b, c):
    _ = [*a, *b, *c]
dis(f)
E esse é o resultado:
  2           2 BUILD_LIST               0
              4 LOAD_FAST                0 (a)
              6 LIST_EXTEND              1
              8 LOAD_FAST                1 (b)
             10 LIST_EXTEND              1
             12 LOAD_FAST                2 (c)
             14 LIST_EXTEND              1
             16 STORE_FAST               3 (_)
             18 RETURN_CONST             0 (None)
Bem parecido com a saída do truque 1, mas aqui LOAD_FAST e LIST_EXTEND são
chamados 3 vezes em vez de apenas 1 (como esperado, já que estamos concatenando
3 listas).
4: Concatenar dicionários
Da mesma forma, você pode concatenar dois ou mais dicionários, porém usando **
    já que estamos lidando com mapeamentos em vez de um iterador:
d1 = {'I': 1, 'V': 5, 'X': 10}
d2 = {'L': 50, 'C': 100}
{**d1, **d2} # = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100}
Repare que você precisa usar ** aqui. Se você usar *, ele vai expandir
apenas as chaves do dicionário, e então vai construir um conjunto em vez
de um dicionário:
{*d1, *d2} # = {'C', 'I', 'L', 'V', 'X'}
Comentário sobre PEP 584
Essa construção era mais útil antes da
PEP 584 em Python 3.9.
Ela introduziu o operador de união (|) para dicionários da mesma forma
como já havia para conjuntos. Hoje em dia você pode juntar dois dicionários
da seguinte forma:
d1 | d2 # = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100}
Isso não significa que este truque se tornou inútil. Você pode usá-la para criar um novo dicionário a partir de outros dicionários e de novas chaves, por exemplo:
{**d1, **d2, 'D': 500, 'M': 1000} # = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C':100, 'D': 500, 'M': 1000}
5: Tuple comprehensions
Em Python nós temos list comprehensions, dictionary comprehensions e set comprehensions. Mas não temos tuple comprehensions:
[2 * x for x in range(10)]     # list comprehension
{2 * x for x in range(10)}     # set comprehension
{x: 2 * x for x in range(10)}  # dict comprehension
(2 * x for x in range(10))     # tuple comp... não, é um gerador!
Mas a gente meio que pode ter uma “tuple comprhension” convertendo um gerador para uma tupla usando o truque 2:
(*(2 * x for x in range(10)),) # "tuple comprehension"
Note: se isso parece útil para você, então talvez tuplas não seja o que você está precisando! Tuplas são mais do que listas imutáveis: tipicamente, você usa tuplas quando cada posição tem um significado, como em um par ordenado. Como sugestão, leia a seção “Tuplas não são apenas listas imutáveis” no livro Python Fluente de Luciano Ramalho.
6: Imprimir listas de forma bonita
Às vezes você quer imprimir uma lista, mas em uma forma mais legível para humano
do que a representação [foo, bar]. Você pode, por exemplo, usar str.join:
my_list = [1, 2, 3]
print(' -> '.join([str(x) for x in my_list])) # 1 -> 2 -> 3
Mas existe uma solução ainda mais limpa: você pode usar * para passar cada
elemento da lista como um parâmetro. Depois você pode especificar qual separador
você quer usar (por padrão, espaço):
print(*my_list)             # 1 2 3
print(*my_list, sep=' -> ') # 1 -> 2 -> 3
Muito mais limpo. Outro exemplo, um código simples para gerar um arquivo CSV em apenas 3 linhas:
data = [
    (1, 2, 3),
    (4, 5, 6),
    (7, 8, 9)
]
with open('output.csv', 'w') as f:
    for row in data:
        print(*row, sep=',', file=f)
7: Transpor matrizes
A função zip itera simultaneamente sobre dois iteradores, por exemplo:
for pair in zip(range(3), range(3, 6)):
    print(pair)   # (0, 3) (1, 4) (2, 5)
Dessa forma, você pode usar * para iterar sobre elementos de todas as linhas
ao mesmo tempo usando zip, ou em outras palavras, iterar sobre as colunas:
my_matrix = [
    (1, 2, 3),
    (4, 5, 6),
    (7, 8, 9)
]
for column in zip(*my_matrix):
    print(column)   # (1, 4, 7) (2, 5, 8) (3, 6, 9)
Se você criar uma lista de colunas, então você terá uma matriz transposta! Você
só precisa converter zip(*my_matrix) para uma lista usando a mesma sintaxe de
antes:
[*zip(*my_matrix)] # [(1, 4, 7), (2, 5, 8), (3, 6, 9)]
E o NumPy?
O NumPy é incrível, é claro! Você pode transpor uma matriz usando my_matrix.T, 
e isso é O(1). Mas você precisará:
- importar NumPy
- usar numpy.arrayou similar
Se você já está usando o NumPy e você precisa transpor uma matriz, vá em frente,
use .T. Mas se você vai usar NumPy apenas para transpor uma matriz, talvez ele
não seja a melhor opção. Importar o NumPy e converter uma matriz de listas do
Python para NumPy custa tempo.
Aqui vai um teste simples (tente rodá-lo em sua máquina)
from time import time
a = time(); import numpy as np; b = time()
print('Numpy loading time: ', b - a)
my_matrix = [[i * 10 + j for j in range(10)] for i in range(10)]
a = time(); transposed = [*zip(*my_matrix)]; b = time()
print('zip time: ', b - a)
a = time(); transposed = np.array(my_matrix).T; b = time()
print('numpy time: ', b - a)
Na minha máquina (um velho i5 de segunda geração), essa é a saída:
Numpy loading time:  0.10406970977783203
zip time:  7.62939453125e-06
numpy time:  1.6450881958007812e-05
Repare que ele tomou 2x mais tempo para criar um np.array a partir de
uma matriz existente e o transpor, e tomou mais de 10000x o tempo para
importar o NumPy! E essa é uma matriz relativamente grande (10x10)!
“Não use um canhão para matar um mosquito” - Confúcio
8: Selecionar elementos sem repetição
Uma das diferenças entre listas e conjuntos é que conjuntos não podem conter o mesmo elemento duas vezes. Então, se nós queremos uma lista com elementos únicos, nós podemos convertê-la para um conjunto e convertê-la de volta para uma lista:
list = [1, 1, 2, 3, 5]
[*{*list}] # [1, 2, 3, 5]
Note que conjuntos não têm ordem. Mas você pode usar um collections.Counter
aqui e ele irá preservar a primeira ocorrência (caso precise):
from collections import Counter
[*Counter(list)] # [1, 2, 3, 5]
# or
Counter(list).elements()
Da mesma forma que antes, você poderia usar
numpy.unique, mas lembre-se de Confúcio!
9: Criar uma lista com substitutos para elementos nulos
Olhe esta lista com dados sobre super-herois (seus nomes, seus nomes reais e seus poderes):
heroes = [
    {
        'name': 'spider-man',
        'real name': 'peter parker',
        'powers': ['wall crawling', 'webbing'],
    },
    
    {
        'name': 'batman',
        'real name': 'bruce wayne',
        'powers': []
    }
]
Se você quer criar uma representação legível para humanos de cada um desses herois, você poderia, por exemplo, fazer isso:
def hero_string(hero):
    s = ''
    s += f'name: {hero['name']}\n'
    s += f'real name: {hero['real name']}\n'
    if hero['powers']:
        for power in hero['powers']:
            s += f'power: {power}\n'
    else:
        s += 'No powers!'
    return s
Repare que para o Spider-Man ele vai mostrar seus poderes e para o Batman ele vai mostrar “No powers!”.
Ok, mas somar strings não é tão eficiente quanto escrever str.join. Uma
segunda solução seria:
def hero_string(hero):
    names = '\n'.join([
        f'name: {hero["name"]}',
        f'real name: {hero["real name"]}'
    ])
    if hero['powers']:
        powers = '\n'.join(f'power: {power}' for power in hero['powers'])
    else:
        powers = 'No powers!'
    return '\n'.join([names, powers])
Bem, nós não conseguimos escrever com só um '\n'.join já que precisamos tratar
o caso especial do Batman… Mas nosso amigo * pode nos ajudar aqui:
def hero_string(hero):
    powers = [f'power: {power}' for power in hero['powers']] or ['No powers!']
    return '\n'.join([
        f'name: {hero["name"]}',
        f'real name: {hero["real name"]}',
        *powers
    ])
Dessa forma, powers vai ser uma lista de linhas contendo os poderes ou
contendo “No Powers!” caso o heroi não tenha poderes. Então, ela será expandida
depois com join.
10: Conferir se todos os elementos estão em um conjunto
Este é meu preferido. Se você quer conferir se todos os elementos de uma lista
estão contidos em outra lista, você pode fazer isso (quando os operandos são
conjuntos, o operador <= significa “é um subconjunto de”):
a = [11, 22, 33]
b = [11, 22, -1]
c = [11]
d = [11, 22, 33, 44]
{*a} <= {*d} # true
{*b} <= {*d} # false
{*c} <= {*d} # false
Você também pode usar == para conferir se os elementos de duas são os mesmos
(mesmo que sua ordem seja diferente ou que eles tenham elementos repetidos):
{*a} == {*d}
Você também pode usar isso com strings. Este exemplo checa se uma string contém apenas vogais:
{*my_string} <= {*'aeiou'}
Complexidade
Uma abordagem mais imperativa sobre isso seria:
result = True
for x in a:               # O(len(a))
   if x not in d:         # O(len(d))
       result = False
       break
A operação in sobre listas é O(n), então, essa abordagem imperativa é 
O(len(a) * len(d)) no pior caso e O(len(d)) no melhor caso.
De volta à nossa solução anterior, a criação de um conjunto é O(n) e a operaçao
set1 <= set2 é O(len(set1)), então, nossa solução é O(len(a) + len(b)) no
pior caso. Muito melhor, sem dúvida. Mas no melhor caso ela também vai ser
O(len(a) + len(b)), já que ela precisa iterar sobre todos os elementos de
a e d mesmo que essas listas sejam completamente diferentes! Então, a gente
pode melhorar nossa solução, pelo menos em termos de complexidade.
A nossa solução resolve isso, mas claro que ela não é tão elegante:
set_d = {*d}
all(x in set_d for x in a) # devolverá False na primeira divergência!
Então, essa solução é O(len(d)) no melhor caso (o primeiro elemento de a não
está em d) e ainda será O(len(a) + len(d)) no pior caso (todos os elementos
de a estão em d). Mas para listas pequenas (ou strings, tuplas, etc)
esse tipo de problema não é muito relevante.
Conclusão
Eu espero que você tenha gostado deste texto! Se você conhece algum outro truque
legal usando * ou **, se você encontrou alguma coisa errada, ou se você tem
alguma sugestão, por favor me diga 
aqui.