Características das Linguagens Orientadas a Objetos

Beginner

This tutorial is from open-source community. Access the source code

Introdução

Bem-vindo a Características de Linguagens Orientadas a Objetos. Este laboratório faz parte do Livro Rust. Você pode praticar suas habilidades em Rust no LabEx.

Neste laboratório, exploraremos as características das linguagens orientadas a objetos, incluindo objetos, encapsulamento e herança, e examinaremos se Rust suporta essas funcionalidades.

Características de Linguagens Orientadas a Objetos

Não há consenso na comunidade de programação sobre quais características uma linguagem deve ter para ser considerada orientada a objetos. Rust é influenciado por muitos paradigmas de programação, incluindo OOP (Programação Orientada a Objetos); por exemplo, exploramos as características que vieram da programação funcional no Capítulo 13. Argumentavelmente, as linguagens OOP compartilham certas características comuns, nomeadamente objetos, encapsulamento e herança. Vamos analisar o que cada uma dessas características significa e se Rust as suporta.

Objetos Contêm Dados e Comportamento

O livro Design Patterns: Elements of Reusable Object-Oriented Software de Erich Gamma, Richard Helm, Ralph Johnson e John Vlissides (Addison-Wesley, 1994), coloquialmente referido como o livro The Gang of Four, é um catálogo de padrões de design orientado a objetos. Ele define OOP da seguinte forma:

Programas orientados a objetos são compostos por objetos. Um objeto empacota tanto dados quanto os procedimentos que operam nesses dados. Os procedimentos são tipicamente chamados de métodos ou operações.

Usando esta definição, Rust é orientado a objetos: structs e enums têm dados, e blocos impl fornecem métodos em structs e enums. Embora structs e enums com métodos não sejam chamados de objetos, eles fornecem a mesma funcionalidade, de acordo com a definição de objetos do Gang of Four.

Encapsulamento que Oculta Detalhes de Implementação

Outro aspecto comumente associado à OOP é a ideia de encapsulamento, o que significa que os detalhes de implementação de um objeto não são acessíveis ao código que usa esse objeto. Portanto, a única maneira de interagir com um objeto é através de sua API pública; o código que usa o objeto não deve ser capaz de acessar os detalhes internos do objeto e alterar dados ou comportamento diretamente. Isso permite que o programador altere e refatore os detalhes internos de um objeto sem precisar alterar o código que usa o objeto.

Discutimos como controlar o encapsulamento no Capítulo 7: podemos usar a palavra-chave pub para decidir quais módulos, tipos, funções e métodos em nosso código devem ser públicos, e por padrão todo o resto é privado. Por exemplo, podemos definir uma struct AveragedCollection que possui um campo contendo um vetor de valores i32. A struct também pode ter um campo que contém a média dos valores no vetor, o que significa que a média não precisa ser calculada sob demanda sempre que alguém precisar dela. Em outras palavras, AveragedCollection irá armazenar em cache a média calculada para nós. A Listagem 17-1 tem a definição da struct AveragedCollection.

Nome do arquivo: src/lib.rs

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

Listagem 17-1: Uma struct AveragedCollection que mantém uma lista de inteiros e a média dos itens na coleção

A struct é marcada como pub para que outro código possa usá-la, mas os campos dentro da struct permanecem privados. Isso é importante neste caso porque queremos garantir que sempre que um valor for adicionado ou removido da lista, a média também seja atualizada. Fazemos isso implementando os métodos add, remove e average na struct, conforme mostrado na Listagem 17-2.

Nome do arquivo: src/lib.rs

impl AveragedCollection {
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }

    pub fn remove(&mut self) -> Option<i32> {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            }
            None => None,
        }
    }

    pub fn average(&self) -> f64 {
        self.average
    }

    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}

Listagem 17-2: Implementações dos métodos públicos add, remove e average em AveragedCollection

Os métodos públicos add, remove e average são as únicas maneiras de acessar ou modificar dados em uma instância de AveragedCollection. Quando um item é adicionado à list usando o método add ou removido usando o método remove, as implementações de cada um chamam o método privado update_average que lida com a atualização do campo average também.

Deixamos os campos list e average privados para que não haja como o código externo adicionar ou remover itens para ou da campo list diretamente; caso contrário, o campo average pode ficar fora de sincronia quando a list muda. O método average retorna o valor no campo average, permitindo que o código externo leia a average, mas não a modifique.

Como encapsulamos os detalhes de implementação da struct AveragedCollection, podemos facilmente alterar aspectos, como a estrutura de dados, no futuro. Por exemplo, poderíamos usar um HashSet<i32> em vez de um Vec<i32> para o campo list. Contanto que as assinaturas dos métodos públicos add, remove e average permanecessem as mesmas, o código que usa AveragedCollection não precisaria ser alterado. Se tornássemos list público, isso não seria necessariamente o caso: HashSet<i32> e Vec<i32> têm métodos diferentes para adicionar e remover itens, então o código externo provavelmente teria que mudar se estivesse modificando list diretamente.

Se o encapsulamento é um aspecto obrigatório para que uma linguagem seja considerada orientada a objetos, então Rust atende a esse requisito. A opção de usar pub ou não para diferentes partes do código permite o encapsulamento de detalhes de implementação.

Herança como um Sistema de Tipos e como Compartilhamento de Código

Herança é um mecanismo pelo qual um objeto pode herdar elementos da definição de outro objeto, ganhando assim os dados e o comportamento do objeto pai sem que você precise defini-los novamente.

Se uma linguagem deve ter herança para ser orientada a objetos, então Rust não é essa linguagem. Não há como definir uma struct que herde os campos e implementações de métodos da struct pai sem usar uma macro.

No entanto, se você está acostumado a ter herança em sua caixa de ferramentas de programação, você pode usar outras soluções em Rust, dependendo do seu motivo para usar herança em primeiro lugar.

Você escolheria herança por duas razões principais. Uma é para reutilização de código: você pode implementar um comportamento específico para um tipo, e a herança permite que você reutilize essa implementação para um tipo diferente. Você pode fazer isso de forma limitada no código Rust usando implementações de métodos de trait padrão, que você viu na Listagem 10-14 quando adicionamos uma implementação padrão do método summarize no trait Summary. Qualquer tipo que implemente o trait Summary teria o método summarize disponível nele sem nenhum código adicional. Isso é semelhante a uma classe pai que tem uma implementação de um método e uma classe filha herdeira também tendo a implementação do método. Também podemos substituir a implementação padrão do método summarize quando implementamos o trait Summary, o que é semelhante a uma classe filha substituindo a implementação de um método herdado de uma classe pai.

A outra razão para usar herança está relacionada ao sistema de tipos: para permitir que um tipo filho seja usado nos mesmos lugares que o tipo pai. Isso também é chamado de polimorfismo, o que significa que você pode substituir vários objetos uns pelos outros em tempo de execução se eles compartilharem certas características.

Polimorfismo

Para muitas pessoas, polimorfismo é sinônimo de herança. Mas, na verdade, é um conceito mais geral que se refere ao código que pode trabalhar com dados de vários tipos. Para herança, esses tipos são geralmente subclasses.

Em vez disso, Rust usa genéricos para abstrair sobre diferentes tipos possíveis e limites de trait para impor restrições sobre o que esses tipos devem fornecer. Isso às vezes é chamado de polimorfismo paramétrico limitado.

A herança caiu em desuso recentemente como uma solução de design de programação em muitas linguagens de programação porque muitas vezes corre o risco de compartilhar mais código do que o necessário. Subclasses nem sempre devem compartilhar todas as características de sua classe pai, mas o farão com herança. Isso pode tornar o design de um programa menos flexível. Também introduz a possibilidade de chamar métodos em subclasses que não fazem sentido ou que causam erros porque os métodos não se aplicam à subclasse. Além disso, algumas linguagens só permitirão herança única (o que significa que uma subclasse só pode herdar de uma classe), restringindo ainda mais a flexibilidade do design de um programa.

Por essas razões, Rust adota a abordagem diferente de usar objetos de trait em vez de herança. Vamos ver como os objetos de trait permitem o polimorfismo em Rust.

Resumo

Parabéns! Você concluiu o laboratório Características das Linguagens Orientadas a Objetos. Você pode praticar mais laboratórios no LabEx para aprimorar suas habilidades.