A programação orientada a objetos (POO) é um pilar essencial no desenvolvimento de software, frequentemente vista como um conceito complexo, mas que, na realidade, simplifica a estruturação de sistemas. Em essência, POO preconiza que todas as operações e interações dentro de um programa devem ser realizadas através de entidades denominadas objetos.
- Para manipular um navegador web, interagimos com o objeto
window. - Para modificar o conteúdo de uma página, utilizamos o objeto
document. - Para exibir mensagens no console, recorremos ao objeto
console.
Essa abordagem, onde tudo se baseia em objetos, deriva da natureza da computação: programas são abstrações da realidade. Uma fotografia é uma abstração de uma pessoa real; um modelo em miniatura é uma abstração de um veículo. No software, podemos representar diversas entidades do mundo real, como um indivíduo, um animal ou um item específico. Cada uma dessas representações se materializa como um objeto no contexto do programa.
Objetos são tipicamente compostos por duas partes: dados e funcionalidades. Tomemos um indivíduo como exemplo: seu nome, gênero, idade e altura são dados; enquanto falar, andar, comer e dormir são funcionalidades. No âmbito da POO, os dados são referidos como atributos (ou propriedades) e as funcionalidades como métodos. Assim, em um ambiente orientado a objetos, podemos afirmar que "tudo é um objeto".
- Classes
Para interagir com objetos, primeiro precisamos criá-los. A criação de objetos é precedida pela definição de uma classe. Uma classe pode ser entendida como um projeto, um molde ou uma planta para a construção de objetos. Com base em uma classe, o programa pode instanciar objetos de um tipo específico. Por exemplo, uma classe Pessoa pode ser usada para criar objetos que representam pessoas, e uma classe Carro para criar objetos que representam carros.
Definindo uma Classe:
class NomeDaClasse {
propriedadeA: TipoA;
propriedadeB: TipoB;
constructor(paramA: TipoA, paramB: TipoB) {
this.propriedadeA = paramA;
this.propriedadeB = paramB;
}
metodoExemplo(): void {
// Lógica do método
}
}
Exemplo Prático:
class Paciente {
nome: string;
registro: number;
constructor(nomeDoPaciente: string, numeroDeRegistro: number) {
this.nome = nomeDoPaciente;
this.registro = numeroDeRegistro;
}
exibirInfo(): void {
console.log(`Paciente: ${this.nome}, Registro: ${this.registro}`);
}
}
Utilizando a Classe:
const novoPaciente = new Paciente('Maria Silva', 12345);
novoPaciente.exibirInfo(); // Saída: Paciente: Maria Silva, Registro: 12345
- Características da Programação Orientada a Objetos
Encapsulamento
O encapsulamento é uma das principais características da POO, que consiste em agrupar dados (atributos) e métodos que operam sobre esses dados em uma única unidade (o objeto). Seu propósito primário é proteger a integridade dos dados, controlando como eles podem ser acessados e modificados. Por padrão, em TypeScript, os atributos de um objeto podem ser alterados livremente. Para garantir a segurança e o controle dos dados, o TypeScript oferece modificadores de acesso.
Atributos Somente Leitura (readonly):
Ao declarar um atributo com a palavra-chave readonly, ele se torna imutável após a inicialização, seja no construtor ou diretamente na declaração.
Modificadores de Acesso em TypeScript:
public(padrão): Atributos e métodos são acessíveis em qualquer lugar – dentro da própria classe, em classes filhas (subclasses) e através de instâncias do objeto.protected: Atributos e métodos são acessíveis dentro da própria classe e em suas subclasses. Não são acessíveis por instâncias externas do objeto.private: Atributos e métodos são acessíveis apenas dentro da própria classe onde foram declarados. Nem subclasses nem instâncias externas podem acessá-los.
Exemplos de Modificadores de Acesso:
public:
class Funcionario {
public nomeCompleto: string; // 'public' é o padrão, pode ser omitido
public setor: string;
constructor(nome: string, area: string) {
this.nomeCompleto = nome; // Acessível e modificável dentro da classe
this.setor = area;
}
saudar(): void {
console.log(`Olá, sou ${this.nomeCompleto} do setor ${this.setor}.`);
}
}
class Gerente extends Funcionario {
constructor(nome: string, area: string) {
super(nome, area);
this.nomeCompleto = `Sr(a). ${nome}`; // Acessível e modificável em subclasses
}
}
const colaborador = new Funcionario('João Neto', 'Vendas');
colaborador.nomeCompleto = 'João Silva'; // Acessível e modificável por uma instância do objeto
colaborador.saudar();
protected:
class EntidadeBase {
protected identificador: string;
protected dataCriacao: Date;
constructor(id: string) {
this.identificador = id; // Acessível dentro da classe
this.dataCriacao = new Date();
}
obterID(): string {
return this.identificador;
}
}
class ItemEstoque extends EntidadeBase {
nomeProduto: string;
constructor(id: string, produto: string) {
super(id);
this.nomeProduto = produto;
this.identificador = id.toUpperCase(); // Acessível e modificável em subclasses
}
}
const item = new ItemEstoque('p-001', 'Caneta');
// item.identificador = 'novo-id'; // Erro: 'identificador' é protected e só pode ser acessado em subclasses
console.log(item.obterID()); // OK: Acesso via método público
private:
class UsuarioSistema {
private senhaHash: string;
private email: string;
constructor(emailUsuario: string, senhaCriptografada: string) {
this.email = emailUsuario; // Acessível dentro da classe
this.senhaHash = senhaCriptografada;
}
autenticar(senhaFornecida: string): boolean {
// Lógica de autenticação usando this.senhaHash
return this.senhaHash === senhaFornecida;
}
alterarEmail(novoEmail: string): void {
this.email = novoEmail;
}
}
// class Administrador extends UsuarioSistema {
// constructor(email: string, senha: string) {
// super(email, senha);
// // this.senhaHash = 'novaSenha'; // Erro: 'senhaHash' é private e não pode ser acessado em subclasses
// }
// }
const user = new UsuarioSistema('admin@exemplo.com', 'hashSeguro123');
// user.senhaHash = 'senhaAberta'; // Erro: 'senhaHash' é private
console.log(user.autenticar('hashSeguro123')); // OK
Acessadores de Propriedades (Getters e Setters)
Quando um atributo é definido como private, ele não pode ser acessado ou modificado diretamente de fora da classe. Para permitir um acesso controlado, é comum definir métodos específicos para ler (getter) e escrever (setter) esses atributos. Esses métodos são conhecidos como acessadores de propriedade.
class Produto {
private _codigoInterno: string; // Usamos '_' para indicar que é um atributo privado
constructor(codigo: string) {
this._codigoInterno = codigo;
}
// Getter para ler o código interno
get codigo(): string {
console.log("Acessando o código do produto...");
return this._codigoInterno;
}
// Setter para modificar o código interno com validação
set codigo(novoCodigo: string) {
if (novoCodigo.length >= 5) {
this._codigoInterno = novoCodigo;
console.log("Código do produto atualizado.");
} else {
console.error("Erro: O código deve ter pelo menos 5 caracteres.");
}
}
}
const meuProduto = new Produto('P123');
console.log(meuProduto.codigo); // Usa o getter: "Acessando o código do produto..." e exibe "P123"
meuProduto.codigo = 'P45678'; // Usa o setter: "Código do produto atualizado."
meuProduto.codigo = 'P9'; // Usa o setter: "Erro: O código deve ter pelo menos 5 caracteres."
Atributos e Métodos Estáticos
Atributos e métodos estáticos pertencem à classe em si, e não a instâncias específicas dela. Eles são acessados diretamente pela classe, sem a necessidade de criar um objeto. São declarados com a palavra-chave static.
class UtilidadesMatematicas {
static VALOR_PI: number = 3.14159; // Atributo estático
static calcularAreaCirculo(raio: number): number { // Método estático
return this.VALOR_PI * raio * raio;
}
}
console.log(UtilidadesMatematicas.VALOR_PI); // Acesso direto ao atributo estático
console.log(UtilidadesMatematicas.calcularAreaCirculo(5)); // Acesso direto ao método estático
A Palavra-chave this
Em TypeScript, dentro do contexto de uma classe ou de seus métodos, this refere-se à instância atual do objeto. Ele é usado para acessar os atributos e chamar os métodos daquela instância específica.
Herança
A herança é um mecanismo que permite que uma nova classe (subclasse ou classe filha) adquira as propriedades e comportamentos (atributos e métodos) de uma classe existente (superclasse ou classe pai). Isso promove a reutilização de código e estabelece uma relação "é um tipo de" entre as classes.
class AnimalDomestico {
nomeCientifico: string;
expectativaVidaAnos: number;
constructor(nomeCientifico: string, vidaAnos: number) {
this.nomeCientifico = nomeCientifico;
this.expectativaVidaAnos = vidaAnos;
}
emitirSom(): void {
console.log('O animal faz um som genérico.');
}
}
class Gato extends AnimalDomestico {
raça: string;
constructor(nomeCientifico: string, vidaAnos: number, tipoRaca: string) {
super(nomeCientifico, vidaAnos); // Chama o construtor da classe pai
this.raça = tipoRaca;
}
miar(): void {
console.log(`O ${this.raça} está miando: Miau!`);
}
// Sobrescrevendo o método emitirSom da classe pai
emitirSom(): void {
console.log('O gato mia.');
}
}
const felino = new Gato('Felis catus', 15, 'Siamês');
felino.miar(); // Saída: O Siamês está miando: Miau!
felino.emitirSom(); // Saída: O gato mia. (Método sobrescrito)
console.log(`Nome científico: ${felino.nomeCientifico}, Vida média: ${felino.expectativaVidaAnos} anos.`);
A herança possibilita estender funcionalidades de classes existentes sem modificá-las diretamente, garantindo a modularidade e a manutenibilidade do código. Em uma subclasse, a palavra-chave super é usada para referenciar a superclasse, permitindo chamar seu construtor ou seus métodos.
Classes Abstratas
Uma classe abstrata é uma classe que não pode ser instanciada diretamente. Ela serve como um modelo para outras classes que a herdarão. Classes abstratas podem conter métodos abstratos, que são declarados sem implementação e devem ser implementados por todas as subclasses concretas.
abstract class FormaGeometrica {
// Método abstrato sem implementação. Deve ser implementado pelas subclasses.
abstract calcularArea(): number;
// Método concreto com implementação
desenhar(): void {
console.log('Desenhando uma forma geométrica...');
}
}
class Retangulo extends FormaGeometrica {
largura: number;
altura: number;
constructor(l: number, a: number) {
super();
this.largura = l;
this.altura = a;
}
// Implementação do método abstrato calcularArea
calcularArea(): number {
return this.largura * this.altura;
}
}
// const forma = new FormaGeometrica(); // Erro: Não é possível criar uma instância de uma classe abstrata
const meuRetangulo = new Retangulo(10, 5);
console.log(`Área do retângulo: ${meuRetangulo.calcularArea()}`); // Saída: Área do retângulo: 50
meuRetangulo.desenhar(); // Saída: Desenhando uma forma geométrica...
- Interfaces
Interfaces em TypeScript são contratos que definem a estrutura de um objeto. Elas especificam quais propriedades e métodos um objeto (ou uma classe) deve possuir. Ao contrário das classes abstratas, interfaces não contêm implementações; elas apenas declaram a "assinatura" do que se espera. Interfaces são usadas para verificar tipos de objetos ou para garantir que uma classe implemente um conjunto específico de funcionalidades.
Exemplo (Verificação de Tipo de Objeto):
interface EntidadeExibivel {
id: string;
exibirDetalhes(): void;
}
function processarExibivel(item: EntidadeExibivel): void {
item.exibirDetalhes();
}
// Um objeto que corresponde à interface EntidadeExibivel
const meuUsuario = {
id: 'user-456',
nome: 'Carlos', // Propriedade extra é permitida, desde que as necessárias estejam lá
exibirDetalhes() {
console.log(`Detalhes do ID: ${this.id}, Nome: ${this.nome}`);
}
};
processarExibivel(meuUsuario); // Saída: Detalhes do ID: user-456, Nome: Carlos
// Um objeto anônimo também pode ser compatível
processarExibivel({ id: 'prod-001', exibirDetalhes() { console.log(`Produto ID: ${this.id}`); } }); // Saída: Produto ID: prod-001
Exemplo (Implementação por Classe):
interface Registravel {
registrarEvento(mensagem: string, nivel: 'info' | 'warn' | 'error'): void;
}
class ConsoleLogger implements Registravel {
constructor(private nomeDoModulo: string) {}
registrarEvento(mensagem: string, nivel: 'info' | 'warn' | 'error'): void {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${this.nomeDoModulo}] [${nivel.toUpperCase()}]: ${mensagem}`);
}
}
const logSistema = new ConsoleLogger('SistemaPrincipal');
logSistema.registrarEvento('Aplicação iniciada com sucesso.', 'info');
logSistema.registrarEvento('Atenção: Disco quase cheio.', 'warn');
- Genéricos (Generics)
Genéricos em TypeScript são uma ferramenta poderosa que permite escrever componentes reutilizáveis que podem trabalhar com vários tipos de dados, sem comprometer a segurança de tipo. Eles resolvem o problema de ter que duplicar código ou usar o tipo any quando o tipo de um parâmetro, retorno ou propriedade não é conhecido no momento da definição da função ou classe.
O Problema com any:
function processarDadosInseguro(dado: any): any {
return dado;
}
No exemplo acima, a função processarDadosInseguro aceita e retorna qualquer tipo. Embora funcione, o uso de any desativa as verificações de tipo do TypeScript, perdendo os benefícios de segurança de tipo e clareza, e não expressa a relação de que o tipo de retorno é o mesmo do parâmetro.
Utilizando Genéricos:
function identidade<T>(valor: T): T {
return valor;
}
Aqui, <T> é o marcador de tipo genérico. T (um nome comum, mas pode ser qualquer identificador) representa um "tipo" que será definido quando a função for chamada. A função identidade agora garante que o tipo do argumento seja o mesmo que o tipo de retorno, mantendo a segurança de tipo.
Como Usar a Função Genérica:
-
Inferência de Tipo (Uso Direto):
let resultadoNumero = identidade(123); // TypeScript infere T como number console.log(resultadoNumero); // 123 let resultadoTexto = identidade("Olá Mundo"); // TypeScript infere T como string console.log(resultadoTexto); // Olá MundoNa maioria dos casos, o TypeScript pode inferir o tipo genérico automaticamente. No entanto, em situações mais complexas, você pode precisar especificá-lo.
-
Especificação Explícita do Tipo:
let meuNumero = identidade<number>(42); // Explicitamente definimos T como number let minhaString = identidade<string>("tipo específico"); // Explicitamente definimos T como string
Múltiplos Tipos Genéricos:
É possível definir vários parâmetros de tipo genérico, separados por vírgulas.
function mapear<Chave, Valor>(id: Chave, conteudo: Valor): { chave: Chave, dado: Valor } {
return { chave: id, dado: conteudo };
}
const itemMapeado = mapear<string, number>("produtoID", 987);
console.log(itemMapeado.chave); // produtoID
console.log(itemMapeado.dado); // 987
Genéricos em Classes:
Classes também podem ser genéricas, permitindo que suas propriedades ou métodos trabalhem com tipos defiindos no momento da instanciação da classe.
class Armazenamento<TipoDado> {
private colecao: TipoDado[] = [];
adicionar(item: TipoDado): void {
this.colecao.push(item);
}
obterPrimeiro(): TipoDado | undefined {
return this.colecao.length > 0 ? this.colecao[0] : undefined;
}
}
const armazenaNumeros = new Armazenamento<number>();
armazenaNumeros.adicionar(100);
armazenaNumeros.adicionar(200);
console.log(armazenaNumeros.obterPrimeiro()); // 100
const armazenaStrings = new Armazenamento<string>();
armazenaStrings.adicionar("item A");
console.log(armazenaStrings.obterPrimeiro()); // item A
Restrições (Constraints) em Genéricos:
Para garantir que um tipo genérico possua certas propriedades ou métodos, podemos impor restrições usando a palavra-chave extends com uma interface ou classe. Isso permite acessar membros específicos do tipo genérico com segurança.
interface ComprimentoVerificavel {
tamanho: number;
}
// A função só aceitará tipos que tenham a propriedade 'tamanho'
function logarTamanho<T extends ComprimentoVerificavel>(item: T): void {
console.log(`O item tem um tamanho de: ${item.tamanho}`);
}
logarTamanho({ tamanho: 15, nome: "Objeto Teste" }); // Saída: O item tem um tamanho de: 15
// logarTamanho(10); // Erro: Argumento do tipo 'number' não é atribuível ao parâmetro do tipo 'ComprimentoVerificavel'.