Olá, Giovany. Como vai?
Excelente entrega! Você ilustrou com muita clareza as três principais formas de lidar com módulos e bibliotecas no Python: a importação explícita de múltiplos métodos (from ... import ...), a importação do módulo completo (import ...) e a importação total por curinga (from ... import *).
Todos os seus códigos funcionam perfeitamente e cumprem o papel proposto. Contudo, do ponto de vista de Engenharia de Dados e de boas práticas de desenvolvimento (seguindo as diretrizes oficiais da PEP 8), há uma discussão técnica muito importante sobre a sua terceira solução que vale a pena conhecermos.
Vamos analisar a anatomia de cada abordagem para entender o comportamento delas no mercado:
1ª Abordagem: from random import randrange, sample (Recomendada)
Esta é uma das melhores práticas quando você sabe exatamente quais ferramentas vai usar. Ao importar apenas randrange e sample, você mantém a memória do seu programa limpa (não carrega o restante da biblioteca random) e deixa explícito para qualquer um que ler o seu código quais funções serão utilizadas ao longo do script.
2ª Abordagem: import math (Recomendada para Clareza)
Importar o módulo cheio e chamá-lo via namespace (math.sqrt(n)) é o padrão ouro quando estamos trabalhando com scripts extensos ou com muitas bibliotecas misturadas (como math, numpy e pandas).
O uso do prefixo math. avisa imediatamente para quem está lendo o código de onde aquela função veio, evitando confusões.
3ª Abordagem: from math import * (Evitar no Mercado)
Embora o uso do asterisco (curinga) pareça muito prático porque "libera" todas as funções de uma vez sem precisar digitar o prefixo math., essa prática é fortemente desencorajada no desenvolvimento profissional. Ela causa o que chamamos de Poluição do Espaço de Nomes (Namespace Pollution).
O perigo da colisão de nomes
Imagine que você está criando um sistema grande e faz isso:
from math import *
from minha_biblioteca_customizada import *
# Se ambas as bibliotecas tiverem uma função chamada 'pow',
# a última a ser importada vai sobrescrever a primeira silenciosamente!
resultado = pow(2, 3)
O Python não vai disparar nenhum erro, mas o seu código começará a apresentar comportamentos bizarros e difíceis de rastrear (bugs ocultos), porque você perdeu o controle de qual função está realmente sendo executada.
Uma dica bônus: Otimizando o seu primeiro código
No seu primeiro script, você criou uma lista vazia e usou um laço for de 20 passadas apenas para gerar números aleatórios:
lista = []
for i in range(0, 20):
lista.append(randrange(100))
Uma forma muito mais rápida, performática e que é a cara da Ciência de Dados para resolver isso é utilizar uma List Comprehension (compreensão de lista) em uma única linha:
# O próprio Python cria a lista e executa o laço internamente
lista = [randrange(100) for _ in range(20)]
(Nota: Usamos o caractere sublinhado _ no lugar do i quando a variável do laço não é usada dentro do bloco, avisando ao Python que é apenas um contador de repetição).
Parabéns por explorar as diferentes nuances da linguagem e trazer esses testes comparativos para o fórum. Esse tipo de estudo de sintaxe consolida uma base muito forte em Python!
Espero que possa ter lhe ajudado!