2.2. Boas práticas na escrita de funções#
2.2.1. Motivação#
Você já olhou uma função que você mesmo escreveu há um mês e teve dificuldades de entender em 3 minutos? Se você já passou por isso, então este é o momento certo de refatorar seu código. Se sua função levar mais que 3 minutos para você mesmo entender o código, imagine quanto tempo demoraria para os seus colegas de trabalho.
Se você quer escrever códigos bons que possam ser reutilizados, então eles precisam necessariamente ser legíveis, fáceis de entender. Escrever códigos assim é extremamente importante para cientistas de dados que colaboram com outros membros de equipe em papéis diferentes.
Ao final, desejamos que uma função, não só em Python, mas em qualquer outra linguagem:
seja pequena;
faça uma coisa só;
contenha código com mesmo nível de abstração;
tenha menos do que 4 argumentos;
não tenha código duplicado;
use nomes descritivos.
Essas boas práticas acima tornarão as funções mais fáceis de ler e de depurar para encontrar erros.
Nesta seção, vamos discutir como usar essas 6 práticas para melhorar o código das nossas funções.
2.2.2. Vamos lá#
Vamos começar olhando para a função load_data
abaixo:
"""
Este trecho de código faz download dos arquivos no Google Drive e extrai os dados de treino e teste.
Autora do código: Khuyen Tran
Autor da tradução: Henrique Branco
"""
import xml.etree.ElementTree as ET
import zipfile
from os import listdir
from os.path import isfile, join
import gdown
def main():
load_data(
url="https://drive.google.com/uc?id=...",
output="Twitter.zip",
path_train="Data/train/en",
path_test="Data/test/en",
)
def load_data(url: str, output: str, path_train: str, path_test: str):
# Faz download do Google Drive
output = "Twitter.zip"
gdown.download(url, output, quiet=False)
# Unzipa o arquivo
with zipfile.ZipFile(output, "r") as zip_ref:
zip_ref.extractall(".")
# Obtém os arquivos de treino e teste
tweets_train_files = [
file
for file in listdir(path_train)
if isfile(join(path_train, file)) and file != "truth.txt"
]
tweets_test_files = [
file
for file in listdir(path_test)
if isfile(join(path_test, file)) and file != "truth.txt"
]
# Extrai o texto de cada um dos arquivos
t_train = []
for file in tweets_train_files:
train_doc_1 = [r.text for r in ET.parse(join(path_train, file)).getroot()[0]]
t_train.append(" ".join(t for t in train_doc_1))
t_test = []
for file in tweets_test_files:
test_doc_1 = [r.text for r in ET.parse(join(path_test, file)).getroot()[0]]
t_test.append(" ".join(t for t in test_doc_1))
return t_train, t_test
if __name__ == "__main__":
main()
A função load_data
tenta baixar arquivos do Google Drive e extrair os dados. Mesmo que existam muitos comentários nesta função, é difícil entendê-la em menos de 3 minutos. Isso porque a função:
é extremamente longa;
faz mais do que uma coisa só;
contém código com diferentes níveis de abstração;
tem mais do que 3 argumentos;
tem muita duplicação de código;
não possui um nome mais descritivo.
Vamos mostrar como refatorar essa função na prática, seguindo os 6 pontos mencionados no começo do capítulo.
2.2.3. Pequena#
A função deve ser pequena porque é muito mais fácil de saber o que ela faz. Quão pequena? Na opinião da autora original do livro, Khuyen Tran, as funções raramente devem ter mais do que 20 linhas de código. Devem ser tão pequenas quanto o exemplo abaixo. As funções também não devem ter mais do que um ou dois níveis de indentação:
import zipfile
def unzip_data(output: str):
with zipfile.ZipFile(output, 'r') as zip_ref:
zip_ref.extractall('.')
2.2.4. Fazer uma coisa só#
Uma função deve fazer uma única coisa, e não várias. A função load_data
tenta fazer download dos dados, unzipá-los, ler os nomes dos arquivos que contém dados de treino e teste, e extrair texto de cada um deles.
Por este motivo, ela deveria ser quebrada em várias funções menores, como mostrado abaixo:
download_zip_data_from_google_drive(url, output_path)
unzip_data(output_path)
tweet_train, tweet_test = get_train_test_docs(path_train, path_test)
E cada função deve fazer uma coisa só, de forma bem específica:
import gdown
def download_zip_data_from_google_drive(url: str, output_path: str):
gdown.download(url, output_path, quiet=False)
A função download_zip_data_from_google_drive
somente faz download de um arquivo .zip
do Google Drive e só! Nada mais além disso…
Comentário a parte do livro original:
A grande pergunta que fica aqui é: como saber quando a função faz mais de uma coisa? Um exercício bem simples, proposto pelo autor Robert C. Martin (famoso Uncle Bob) em seu livro Código Limpo é descrever o que a sua função faz em poucas palavras e ler o texto. Se o texto se parecer com este: “Esta função faz isso e depois aquilo” é um forte indicativo de que ela faz mais de uma coisa. Os indicativos são:
vírgulas presentes na descrição verbal ou escrita da função;
palavras ou expressões como
e
,também
,além disso
,em seguida
;código duplicado na função;
presença de instruções if-else na função com o seguinte comportamento:
se for isso, então faça assim, se for aquilo então faça assado
;
2.2.5. Um único nível de abstração#
O código da função extract_texts_from_multiple_files
está em um nível diferente de abstração da função em si…
import xml.etree.ElementTree as ET
from typing import List
def extract_texts_from_multiple_files(path_to_file: str, files: list) -> List[str]:
all_docs = []
for file in files:
list_of_text_in_one_file = [r.text for r in ET.parse(join(path_to_file, file_name)).getroot()[0]]
text_in_one_file_as_string = ' '.join(t for t in list_of_text_in_one_file)
all_docs.append(text_in_one_file_as_string)
return all_docs
O que é nível de abstração?
A autora trouxe uma referência para responder a esta pergunta:
O nível de abstração é a quantidade de complexidade pela qual um sistema é visualizado ou programado. Quanto maior o nível, menos detalhes. Quanto mais baixo o nível, mais detalhes. — PCMag
Em outras palavras, quanto maior a complexidade, menor a abstração.
Baseado na definição acima, temos que:
O nome da função em si
extract_texts_from_multiple_files
está em um alto nível de abstração (baixa complexidade, fácil de entender);Por outro lado, o trecho de código
list_of_text_in_one_file = [r.text for r in ET.parse(join(path_to_file, file_name)).getroot()[0]]
está em um nível baixo de abstração (alta complexidade, difícil de entender).
Para fazer com que o código na função esteja no mesmo nível de abstração, podemos colocar o código “de baixo nível” em outra função separada.
import xml.etree.ElementTree as ET
from typing import List
def extract_texts_from_multiple_files(path_to_file: str, files: list) -> List[str]:
all_docs = []
for file in files:
text_in_one_file = extract_texts_from_each_file(path_to_file, file)
all_docs.append(text_in_one_file)
return all_docs
def extract_texts_from_each_file(path_to_file: str, file_name: list) -> str:
list_of_text_in_one_file =[r.text for r in ET.parse(join(path_to_file, file_name)).getroot()[0]]
text_in_one_file_as_string = ' '.join(t for t in list_of_text_in_one_file)
return text_in_one_file_as_string
Agora temos o código da função - extract_texts_from_each_file(path_to_file, file)
- e a função em si - extract_texts_from_multiple_files
no mesmo nível de abstração.
2.2.6. Código duplicado#
O código a seguir está duplicado. O trecho que é usado para coletar dados de treino é bastante similar ao trecho usado para coletar dados de teste.
import xml.etree.ElementTree as ET
t_train = []
for file in tweets_train_files:
train_doc_1 =[r.text for r in ET.parse(join(path_train, file)).getroot()[0]]
t_train.append(' '.join(t for t in train_doc_1))
t_test = []
for file in tweets_test_files:
test_doc_1 =[r.text for r in ET.parse(join(path_test, file)).getroot()[0]]
t_test.append(' '.join(t for t in test_doc_1))
Deve-se, de forma geral, evitar duplicações pelos seguintes motivos:
É redundante;
Se alteramos um trecho de código, precisamos alterar o trecho similar também. Podemos esquecer de alterar algum trecho, pois somos todos humanos, e acabamos introduzindo bugs em nosso código.
Podemos eliminar o código duplicado encapsulando-o em uma função.
from typing import Tuple, List
def get_train_test_docs(path_train: str, path_test: str) -> Tuple[list, list]:
tweets_train_files = get_files(path_train)
tweets_test_files = get_files(path_test)
t_train = extract_texts_from_multiple_files(path_train, tweets_train_files)
t_test = extract_texts_from_multiple_files(path_test, tweets_test_files)
return t_train, t_test
def extract_texts_from_multiple_files(path_to_file: str, files: list) -> List[str]:
all_docs = []
for file in files:
text_in_one_file = extract_texts_from_each_file(path_to_file, file)
all_docs.append(text_in_one_file)
return all_docs
2.2.7. Nomes descritivos#
Nomes descritivos
A autora trouxe uma definição do Robert C. Martin, também conhecido como “Tio Bob” (Uncle Bob, do inglês):
Um nome descritivo longo é melhor que um nome enigmático curto. Um nome descritivo longo é melhor que um comentário descritivo longo. — Código Limpo de Robert C. Martin
Inclusive recomendo altamente a leitura desta obra citada!
Outras pessoas conseguem entender o que a função extract_texts_from_multiple_files
simplesmente lendo o nome da função.
Não tenham medo de escrever nomes longos. É melhor escrever nomes longos do que vagos. Se você tentar encurtar o nome da função para algo parecido com get_texts
, seria difícil para as outras pessoas entenderem o que a função faz exatamente sem olhar o código dela.
Se o nome da sua função é extremamente longo, como download_file_from_google_drive_and_extract_text_from_that_file
, é um forte sinal de que sua função está fazendo mais do que uma coisa só e deveria ser quebrada em funções menores.
2.2.8. Ter menos que 4 argumentos#
Uma função não deve ter mais do que 3 argumentos, pois pode ser um sinal de que ela faz mais do que uma única tarefa. Sem contar que é difícil testar uma função com mais do que 3 argumentos, pois a combinação entre eles começa a crescer exponencialmente.
Por exemplo, a função load_data
tem 4 argumentos: url
, output_path
, path_train
, and path_test
. Portanto, tem-se uma leve sensação de que ela faz muitas coisas:
Usa a
url
para fazer download do dado;Salva-o em
output_path
;Extrai os dados de
output_path
e os salva empath_train
epath_test
.
Dica
Se a função tem mais de 3 argumentos, considere torná-la uma classe!
Por exemplo, nós poderíamos dividir a função load_data
em 3 outras funções diferentes:
download_zip_data_from_google_drive(url, output_path)
unzip_data(output_path)
tweet_train, tweet_test = get_train_test_docs(path_train, path_test)
As três funções tem um objetivo único de extrair dados, podemos criar uma classe chamada DataGetter
.
import xml.etree.ElementTree as ET
import zipfile
from os import listdir
from os.path import isfile, join
from typing import List, Tuple
import gdown
def main():
url = "https://drive.google.com/uc?id=1jI1cmxqnwsmC-vbl8dNY6b4aNBtBbKy3"
output_path = "Twitter.zip"
path_train = "Data/train/en"
path_test = "Data/test/en"
data_getter = DataGetter(url, output_path, path_train, path_test)
tweet_train, tweet_test = data_getter.get_train_test_docs()
class DataGetter:
def __init__(self, url: str, output_path: str, path_train: str, path_test: str):
self.url = url
self.output_path = output_path
self.path_train = path_train
self.path_test = path_test
self.download_zip_data_from_google_drive()
self.unzip_data()
def download_zip_data_from_google_drive(self):
gdown.download(self.url, self.output_path, quiet=False)
def unzip_data(self):
with zipfile.ZipFile(self.output_path, "r") as zip_ref:
zip_ref.extractall(".")
def get_train_test_docs(self) -> Tuple[list, list]:
tweets_train_files = self.get_files(self.path_train)
tweets_test_files = self.get_files(self.path_test)
t_train = self.extract_texts_from_multiple_files(
self.path_train, tweets_train_files
)
t_test = self.extract_texts_from_multiple_files(
self.path_test, tweets_test_files
)
return t_train, t_test
@staticmethod
def get_files(path: str) -> List[str]:
return [
file
for file in listdir(path)
if isfile(join(path, file)) and file != "truth.txt"
]
def extract_texts_from_multiple_files(
self, path_to_file: str, files: list
) -> List[str]:
all_docs = []
for file in files:
text_in_one_file = self.extract_texts_from_each_file(path_to_file, file)
all_docs.append(text_in_one_file)
return all_docs
@staticmethod
def extract_texts_from_each_file(path_to_file: str, file_name: list) -> str:
list_of_text_in_one_file = [
r.text for r in ET.parse(join(path_to_file, file_name)).getroot()[0]
]
text_in_one_file_as_string = " ".join(t for t in list_of_text_in_one_file)
return text_in_one_file_as_string
if __name__ == "__main__":
main()
Nota
No código acima a autora usou o decorador staticmethod
para alguns métodos, pois eles não usam nenhum atributo ou método da classe. Ela também indicou este site para buscar por mais informações.
Como podemos observar, nenhuma das funções ou métodos acima, com exceção do construtor, tem mais do que 3 argumentos! E embora o código que usa o paradigma da programação orientada à objetos seja bem mais longo, ele é muito mais legível. Sabemos, também, o que cada trecho de código faz de forma bem específica.
2.2.9. Como escrever funções como estas?#
Não tente escrever o código perfeito de primeira. Escreva códigos complexos que você tem em mente. Conforme o seu código cresce, pergunte-se se as suas funções violam alguma das boas práticas mencionadas acima. Se sim, refatore-as, teste-as, e mova para próxima função.
2.2.10. Conclusão#
Você acabou de aprender as 6 melhores práticas para escrever funções mais legíveis e, ao mesmo tempo, testáveis. Sabendo que cada função faz uma única coisa, você perceberá que a escrita dos testes unitários de cada uma delas será mais fácil e será possível garantir que todos obtenham sucesso quando uma alteração for feita.
Se você não medir esforços para que seus colegas de equipe entendam seu código, eles ficaram eternamente felizes em reutilizá-los em outros projetos.