Encapsulamento de Propriedades, Getters e Setters em Python
Por padrão, os atributos nas classes são públicos, o que significa que podem ser acessados e modificados a partir de qualquer lugar no programa. Por exemplo:
class Person:
    def __init__(self, name, age):
        self.name = name    # define o nome
        self.age = age      # define a idade
    def print_person(self):
        print(f"Nome: {self.name}\tIdade: {self.age}")
tom = Person("Tom", 39)
tom.name = "Homem-Aranha"      # altera o atributo name
tom.age = -129                 # altera o atributo age
tom.print_person()             # Nome: Homem-Aranha     Idade: -129No entanto, isso permite definir valores incorretos para os atributos, como uma idade negativa, o que é indesejável. Surge, então, a necessidade de controlar o acesso aos atributos do objeto.
Essa questão está relacionada ao conceito de encapsulamento. Encapsulamento é uma ideia central da programação orientada a objetos que consiste em ocultar a funcionalidade e evitar o acesso direto a ela.
Python permite definir atributos privados. Para isso, o nome do atributo deve começar com dois sublinhados, como __name. Vamos reescrever o exemplo, tornando name e age atributos privados:
class Person:
    def __init__(self, name, age):
        self.__name = name    # define o nome
        self.__age = age      # define a idade
    def print_person(self):
        print(f"Nome: {self.__name}\tIdade: {self.__age}")
tom = Person("Tom", 39)
tom.__name = "Homem-Aranha"    # tenta alterar o atributo __name
tom.__age = -129               # tenta alterar o atributo __age
tom.print_person()             # Nome: Tom     Idade: 39Mesmo tentando definir novos valores para __name e __age, o método print_person mostra que os valores dos atributos permanecem inalterados.
Isso ocorre porque, ao declarar um atributo com dois sublinhados, como __attribute, Python realmente cria um atributo com um padrão de nome interno _ClassName__attribute. No exemplo acima, os atributos se tornam _Person__name e _Person__age, acessíveis apenas dentro da classe. Se tentarmos modificar esses atributos externamente, não teremos o efeito desejado:
tom.__age = 43Aqui, é criado um novo atributo __age, sem qualquer relação com self.__age ou, mais precisamente, self._Person__age.
Caso tentemos acessar o atributo recém-criado sem antes tê-lo definido, obteremos um erro de execução:
print(tom.__age)Ainda assim, a privacidade desses atributos é relativa. Podemos acessar os valores usando o nome completo do atributo:
class Person:
    def __init__(self, name, age):
        self.__name = name    # define o nome
        self.__age = age      # define a idade
    def print_person(self):
        print(f"Nome: {self.__name}\tIdade: {self.__age}")
tom = Person("Tom", 39)
tom._Person__name = "Homem-Aranha"    # altera o atributo __name
tom.print_person()                    # Nome: Homem-Aranha   Idade: 39Métodos de Acesso: Getters e Setters
Para acessar atributos privados, geralmente usamos métodos de acesso. Um getter permite obter o valor de um atributo, enquanto um setter permite defini-lo. Vamos modificar o exemplo anterior, adicionando métodos de acesso:
class Person:
    def __init__(self, name, age):
        self.__name = name    # define o nome
        self.__age = age      # define a idade
    # setter para definir a idade
    def set_age(self, age):
        if 0 < age < 110:
            self.__age = age
        else:
            print("Idade inválida")
    # getter para obter a idade
    def get_age(self):
        return self.__age
    # getter para obter o nome
    def get_name(self):
        return self.__name
    def print_person(self):
        print(f"Nome: {self.__name}\tIdade: {self.__age}")
tom = Person("Tom", 39)
tom.print_person()  # Nome: Tom  Idade: 39
tom.set_age(-3486)  # Idade inválida
tom.set_age(25)
tom.print_person()  # Nome: Tom  Idade: 25O getter para idade é implementado assim:
def get_age(self):
    return self.__ageO setter é definido para validar o valor de idade antes de permitir sua alteração:
def set_age(self, age):
    if 0 < age < 110:
        self.__age = age
    else:
        print("Idade inválida")A intermediação do acesso aos atributos com métodos permite aplicar lógica adicional. Assim, podemos decidir se devemos redefinir a idade dependendo da validade do valor.
Decorador @property
Outro modo de implementar acesso a atributos é usando decorador @property, que tornam o código mais elegante.
Para criar uma propriedade getter, usa-se @property. Para um setter, @nome_da_propriedade.setter. Vejamos o exemplo anterior usando decoradores:
class Person:
    def __init__(self, name, age):
        self.__name = name    # define o nome
        self.__age = age      # define a idade
    # propriedade getter
    @property
    def age(self):
        return self.__age
    # propriedade setter
    @age.setter
    def age(self, age):
        if 0 < age < 110:
            self.__age = age
        else:
            print("Idade inválida")
    @property
    def name(self):
        return self.__name
    def print_person(self):
        print(f"Nome: {self.__name}\tIdade: {self.__age}")
tom = Person("Tom", 39)
tom.print_person()  # Nome: Tom  Idade: 39
tom.age = -3486     # Idade inválida  (usa o setter)
print(tom.age)      # 39 (usa o getter)
tom.age = 25        # (usa o setter)
tom.print_person()  # Nome: Tom  Idade: 25Aqui, o setter deve ser definido após o getter, ambos com o nome age. Assim, o acesso ao atributo pode ser feito diretamente por tom.age, tanto para leitura quanto para atribuição.