BT

Disseminando conhecimento e inovação em desenvolvimento de software corporativo.

Contribuir

Tópicos

Escolha a região

Início Artigos Destaque do recurso Java: Classes seladas

Destaque do recurso Java: Classes seladas

Favoritos

Pontos Principais

 
  • O lançamento do Java SE 15 em setembro de 2020 apresenta as classes seladas (Sealed Classes, JEP 360 como sendo um recurso novo;
  • Uma classe selada é uma classe ou interface que restringe quais outras classes ou interfaces podem estendê-la;
  • As classes seladas, como enums, capturam alternativas em modelos de domínio, permitindo que programadores e compiladores pensem sobre a sua exaustividade;
  • As classes seladas também são úteis para criar hierarquias seguras, desacoplando a acessibilidade da extensibilidade, permitindo que os desenvolvedores de bibliotecas exponham interfaces enquanto ainda controlam todas as implementações;
  • As classes seladas trabalham junto com registros e correspondência de padrões para oferecer suporte a uma forma de programação centrada nos dados.

O Java SE 15, lançado em setembro de 2020, apresenta as classes seladas (classes Sealed Classes) como um recurso novo, permitindo que classes e interfaces tenham mais controle sobre seus subtipos permitidos. Isso é útil para modelagem de domínio geral e para construir bibliotecas de plataforma mais seguras.

Uma classe ou interface pode ser declarada como sealed, o que significa que apenas um conjunto específico de classes ou interfaces pode estendê-la diretamente:

sealed interface Shape
    permits Circle, Rectangle { ... }

Esse código declara uma interface selada chamada Shape. A lista permits significa que apenas o Circle e o Rectangle podem implementar a interface Shape. (Em alguns casos, o compilador pode ser capaz de inferir a cláusula de permits) Qualquer outra classe ou interface que tentar estender o Shape receberá um erro de compilação (ou um erro em tempo de execução, se tentar trapacear e gerar um erro off-label classfile que declara o Shape como um supertipo).

Nós já estamos familiarizados com a noção de restringir a extensão por meio das classes final. A selagem pode ser considerada uma generalização do final. Restringir o conjunto de subtipos permitidos pode levar a dois benefícios: O autor de um supertipo pode raciocinar melhor sobre as possíveis implementações, uma vez que pode controlar todas as implementações, e o compilador pode raciocinar melhor sobre a exaustividade (como nas instruções switch ou nas conversões de cast). As classes seladas também combinam perfeitamente com os records.

Soma e tipos de produto

A declaração da interface acima afirma que uma classe Shape pode ser uma classe Circle ou uma classe Rectangle e nada mais. Em outras palavras, o conjunto Shape é igual ao conjunto de todas as classes Circle mais o conjunto de todas as classes Rectangle. Por esse motivo, as classes seladas são frequentemente chamadas de tipos de soma, porque o conjunto de valores é a soma dos conjuntos de valores de uma lista fixa de outros tipos. Tipos de soma e de classes seladas não são algo novo. O Scala também tem classes seladas e o Haskell e o ML possuem primitivas para definir tipos de soma (às vezes chamadas de uniões marcadas ou uniões discriminadas).

Os tipos de soma são frequentemente encontrados ao lado dos tipos de produto. Os records, recentemente introduzidos no Java são uma forma de tipo de produto, assim chamado porque o espaço de estado é (um subconjunto) do produto cartesiano dos espaços de estado dos componentes. (Se isso parece complicado, pense nos tipos de produto como tuplas e nos records como tuplas nominais). Vamos terminar a declaração do Shape usando o record para declarar os subtipos:

sealed interface Shape
    permits Circle, Rectangle {

      record Circle(Point center, int radius) implements Shape { }

      record Rectangle(Point lowerLeft, Point upperRight) implements Shape { } 
}

Aqui podemos ver como os tipos de soma e de produto se combinam. Nós podemos dizer "um círculo (circle) é definido por um centro (center) e um raio (radius)", "um retângulo (rectangle) é definido por dois pontos (points)", e finalmente, "uma forma (shape) é um círculo (circle) ou um retângulo (rectangle)". Como esperamos que seja comum co-declarar o tipo base com as implementações desta maneira, quando todos os subtipos são declarados na mesma unidade de compilação, permitimos que a cláusula permits seja omitida e inferimos que seja o conjunto de subtipos declarados nessa unidade de compilação:

sealed interface Shape {

      record Circle(Point center, int radius) implements Shape { }

      record Rectangle(Point lowerLeft, Point upperRight) implements Shape { } 
}

Espera! Isso não está violando o encapsulamento?

Historicamente, a modelagem orientada a objetos nos encorajou a ocultar o conjunto de implementações de um tipo abstrato. Fomos desencorajados a fazer a pergunta: "Quais são os possíveis subtipos do Shape"? E, da mesma forma, nos disseram que fazer um downcast para uma classe de implementação específica é um "code smell". Então, por que de repente, estamos adicionando recursos de linguagem que aparentemente vão contra os princípios históricos? Nós também poderíamos fazer a mesma pergunta sobre os records: Não é uma violação do encapsulamento exigir um relacionamento específico entre uma representação de classes e a API?

A resposta é um grande "depende". Ao modelar um serviço abstrato, é benéfico que os clientes interagem apenas com o serviço por meio de um tipo abstrato, afinal, isso reduz o acoplamento e maximiza a flexibilidade para evolução do sistema. No entanto, ao modelar um domínio específico onde as características desse domínio já são bem conhecidas, o encapsulamento pode nos oferecer menos recursos. Como vimos com os records, ao modelar algo tão trivial como um ponto XY ou uma cor RGB, usar toda a generalidade dos objetos para modelar os dados requer muito trabalho de baixo valor e que muitas vezes pode ofuscar o que realmente está acontecendo. Nestes casos, o encapsulamento tem custos não justificados para todos os seus benefícios. Modelar dados como dados é mais simples e direto.

Os mesmos argumentos se aplicam as classes seladas. Ao modelar um domínio estável e bem compreendido, o encapsulamento do "não vamos dizer que tipos de formas (shapes) existem", não confere necessariamente os benefícios que esperaríamos obter da abstração opaca e pode até dificultar mais ainda para os clientes trabalharem com o que na verdade é um domínio simples.

Isso não significa que o encapsulamento seja um erro. Significa que às vezes, o equilíbrio entre custos e benefícios está fora de linha e podemos usar o julgamento para determinar quando isso ajuda e quando atrapalha. Ao escolher expor ou ocultar a implementação, nós devemos ter a clareza sobre quais são os benefícios e custos do encapsulamento. Estamos querendo flexibilidade para desenvolver a implementação ou ela é apenas uma barreira destruidora de informações no caminho de algo que já é óbvio para os demais players? Frequentemente, os benefícios do encapsulamento são substanciais, mas em casos de hierarquias simples que modelam domínios bem compreendidos, a sobrecarga de declarar abstrações específicas pode, às vezes, não valer a pena.

Quando um tipo como Shape se compromete não apenas com a interface, mas também com as classes que o implementam, podemos sentir a necessidade de se perguntar "Você é um circle" e desta forma é feito um casting para o Circle, uma vez que Shape especificamente nomeou o Circle como sendo um dos subtipos conhecidos. Assim como os records são um tipo de classe mais transparente, as somas são um tipo mais transparente de polimorfismo. É por isso que somas e produtos são vistos juntos com tanta frequência, ambos representam um tipo semelhante de compensação entre transparência e abstração, portanto, onde um faz sentido, o outro provavelmente fará também. As somas dos produtos são frequentemente chamadas de tipos de dados algébricos.

Exaustividade

As classes seladas como Shape comprometem-se com uma lista exaustiva de subtipos possíveis, o que ajuda os programadores e compiladores a raciocinar sobre as formas (shapes) de uma maneira que não poderíamos caso não tivessem essas informações. Outras ferramentas também podem tirar proveito dessas informações, como o Javadoc que lista os subtipos permitidos na página de documentação gerada para uma classe selada.

O Java SE 14 apresenta uma forma limitada de padrão matching, que será estendida no futuro. A primeira versão nos permite usar um tipo de padrão no instanceof:

if (shape instanceof Circle c) {
    // o compilador já realizou o cast no shape como Circle para nós e a vinculou ao c
    System.out.printf("Circle of radius %d%n", c.radius()); 
}

É um pequeno salto para o uso de padrões de tipo no switch. O que fizemos não é compatível com Java SE 15, mas será lançado em breve. Quando nós chegarmos lá, nós podemos calcular a área de um shape usando uma expressão switch cujos os rótulos do case são padrões de tipo, como os seguintes:

float area = switch (shape) {
    case Circle c -> Math.PI * c.radius() * c.radius();
    case Rectangle r -> Math.abs((r.upperRight().y() -
r.lowerLeft().y())
                                 * (r.upperRight().x() -
r.lowerLeft().x()));
    // nenhum padrão é necessário!
}

A contribuição da selagem aqui é que não precisamos de uma cláusula default , porque o compilador já sabe da declaração do Shape, que o Circle e o Rectangle cobrem todas as shapes e portanto, uma cláusula default seria inacessível na opção acima. O compilador ainda insere silenciosamente uma cláusula padrão de lançamento em expressões no switch, apenas no caso dos subtipos permitidos do Shape terem mudado entre o tempo de compilação e o de execução, mas não há necessidade de insistir que o programador escreva esta cláusula padrão "por desencargo de consciência"). Isso é semelhante a como tratamos outra fonte de exaustividade, uma expressão switch sobre uma enum que cobre todas as constantes conhecidas também não precisa de uma cláusula padrão, e geralmente é uma boa ideia omiti-la, pois existe uma probabilidade maior de nos alertar sobre o esquecimento de um caso.

Uma hierarquia como o Shape dá aos clientes uma escolha: Podem lidar com os shapes por meio da interface abstrata, mas também podem "desdobrar" a abstração e interagir por meio de tipos mais nítidos quando fizer sentido. Recursos de linguagem, como a correspondência de padrões, tornam esse tipo de desdobramento mais agradável de se ler e de se escrever.

Exemplos de tipos de dados algébricos

O padrão de "soma de produtos" pode ser poderoso. Para que seja apropriado, deve ser extremamente improvável que a lista de subtipos mude e que consigamos prever que será mais fácil e mais útil para os clientes discriminar os subtipos diretamente.

Comprometer-se com um conjunto fixo de subtipos e incentivar os clientes a usá-los diretamente é uma forma de estreitamento do acoplamento. Caso tudo fique igual, somos encorajados a usar o acoplamento flexível nos projetos para maximizar a flexibilidade para mudar as coisas no futuro, mas esse acoplamento flexível também tem um custo. Abstrações "opacas" e "transparentes" na linguagem nos permite escolher a ferramenta certa para a situação.

Um lugar onde poderíamos usar uma soma de produtos (se isso fosse uma opção na época) é na API do java.util.concurrent.Future. Um Future representa uma computação que pode ser executada simultaneamente com o iniciador, o cálculo representado por um Future pode ainda não ter sido iniciado, iniciado mas não concluído, concluído com êxito ou com uma exceção, ter expirado ou ter sido cancelado por interrupção. O método get() na interface Future reflete todas estas possibilidades:

interface Future<V> {
    …
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException,
TimeoutException;
}

Se o cálculo ainda não foi concluído, o método get() bloqueia até que um dos modos de conclusão ocorra e se for bem-sucedido, retorna o resultado do cálculo. Se o cálculo for concluído lançando uma exceção, ela será encapsulada em uma ExecutionException, se o cálculo expirou ou foi interrompido, um tipo diferente de exceção será executado. Esta API é bastante precisa, mas é um tanto difícil de usar porque há vários caminhos de controle: O caminho normal (onde o get() retorna um valor) e vários caminhos de falha, onde cada um deve ser tratado em um bloco catch:

try {
    V v = future.get();
    // lidando com a conclusão normal
}
catch (TimeoutException e) {
    // lidando com o limite de tempo
}
catch (InterruptedException e) {
    // lidando com o cancelamento
}
catch (ExecutionException e) {
    Throwable cause = e.getCause();
    // lidando com uma falha de tarefa
}

Se nós tivéssemos classes, records e um padrão de matching selados quando o Future foi introduzido no Java 5, seria possível definir o tipo de retorno da seguinte maneira:

sealed interface AsyncReturn<V> {
    record Success<V>(V result) implements AsyncReturn<V> { }
    record Failure<V>(Throwable cause) implements AsyncReturn<V> { }
    record Timeout<V>() implements AsyncReturn<V> { }
    record Interrupted<V>() implements AsyncReturn<V> { }
}

…

interface Future<V> {
    AsyncReturn<V> get();
}

Neste caso, estamos dizendo que um resultado assíncrono é um sucesso (que carrega um valor de retorno), uma falha (que carrega uma exceção), um tempo limite ou um cancelamento. Esta é uma descrição mais uniforme dos resultados possíveis, ao invés de descrever alguns deles com o valor de retorno e outros com exceções. Os clientes ainda teriam que lidar com todos os casos, nós não temos como evitar o fato de que a tarefa possa falhar, mas podemos lidar com os casos de maneira uniforme e mais compacta:

AsyncResult<V> r = future.get();
switch (r) {
    case Success(var result): …
    case Failure(Throwable cause): …
    case Timeout(), Interrupted(): …
}

As somas dos produtos são enums generalizados

Uma boa maneira de pensar sobre as somas dos produtos é que são uma generalização de enums. Uma declaração de uma enum declara um tipo com um conjunto exaustivo de instâncias constantes:

enum Planet { MERCURY, VENUS, EARTH, ... }

É possível associar os dados a cada constante, como a mass e o radius do planet:

enum Planet {
    MERCURY (3.303e+23, 2.4397e6),
    VENUS (4.869e+24, 6.0518e6),
    EARTH (5.976e+24, 6.37814e6),
    …
}

Generalizando um pouco, uma classe selada enumerada não é uma lista fixa de instâncias da classe selada, mas sim uma lista fixa de tipos de instâncias. Por exemplo, esta interface selada lista vários tipos de corpos celestes e os dados relevantes para cada tipo:

sealed interface Celestial {
    record Planet(String name, double mass, double radius)
        implements Celestial {}
    record Star(String name, double mass, double temperature)
        implements Celestial {}
    record Comet(String name, double period, LocalDateTime lastSeen)
       implements Celestial {}
}

Assim como podemos alternar exaustivamente uma enum constante, também somos capazes de alternar exaustivamente sobre os vários tipos de corpos celestes:

switch (celestial) {
    case Planet(String name, double mass, double radius): …
    case Star(String name, double mass, double temp): …
    case Comet(String name, double period, LocalDateTime lastSeen):
…
}

Os exemplos desse padrão aparecem em todos os lugares, eventos em um sistema de UI, códigos de retorno de um sistema orientado a serviços, mensagens em um protocolo, etc.

Hierarquias mais seguras

Até agora, nós falamos sobre quando as classes seladas são úteis para incorporar alternativas em modelos de domínio. Essas classes também têm outra aplicação bastante diferente em termos de hierarquias seguras.

O Java sempre nos permitiu dizer "esta classe não pode ser estendida" marcando a classe como final. A existência do final na linguagem reconhece um fato básico sobre as classes, às vezes, são projetadas para serem estendidas e às vezes não e, nós gostaríamos de oferecer um suporte a ambos os modos. Na verdade, no Effective Java é recomendado que "nós devemos projetar e documentar para extensões, ou então teremos de proibi-las". Este é um conselho excelente e pode ser ouvido com mais frequência se a linguagem nos der mais ajuda para fazê-lo.

Infelizmente, a linguagem Java falha em nos ajudar nesta questão devido a duas coisas: O padrão para as classes é extensível ao invés de final e, o mecanismo final é na realidade bastante fraco, pois força os autores a escolher entre restringir a extensão e usar o polimorfismo como uma técnica de implementação. Um bom exemplo de onde pagamos por essa tensão é a classe String, isto é crítico para a segurança da plataforma que as strings sejam imutáveis e portanto, a String não pode ser extensível publicamente, mas seria bastante conveniente para a implementação ter vários subtipos. O custo de contornar isso é substancial, as strings compactas irão proporcionar uma pegada significativa e melhorias de desempenho ao dar tratamento especial às strings compostas exclusivamente de caracteres Latin-1, mas teria sido muito mais fácil e barato fazer isso se a classe String fosse uma classe sealed ao invés de de uma final.

É um truque bem conhecido para simular o efeito de classes de selagem, mas não de interfaces, usando um construtor de pacote privado e colocando todas as implementações no mesmo pacote. Isso ajuda, mas ainda é um pouco desconfortável expor uma classe abstrata e pública que não deve ser estendida. Os autores de bibliotecas preferem usar interfaces para expor as abstrações opacas. As classes abstratas deveriam ser um auxílio à implementação, não uma ferramenta de modelagem. Como é recomendado no Effective Java: "Prefira interfaces do que classes abstratas".

Com as interfaces seladas, os autores de bibliotecas não precisam mais escolher entre usar o polimorfismo como uma técnica de implementação, permitir extensão não controlada ou expor abstrações como interfaces, pois podem ter todas essas opções. Em tal situação, o autor pode escolher tornar as classes de implementação acessíveis, porém o mais provável é que as classes de implementação permaneçam encapsuladas.

As classes seladas permitem que os autores das bibliotecas separem a acessibilidade da extensibilidade. É bom ter essa flexibilidade, mas quando devemos usá-la? Nós certamente não desejaríamos selar as interfaces como List, afinal, é razoável e desejável que os usuários criem novos tipos de List. A selagem pode ter custos, como os usuários não poderem criar novas implementações, e benefícios, como a implementação poder raciocinar globalmente sobre todas as implementações. Nós devemos economizar o uso da selagem para quando os benefícios excederem os custos.

As letras miúdas

O modificador sealed pode ser aplicado a classes ou interfaces. É um erro tentar selar uma classe final, seja explicitamente declarada com o modificador final ou implicitamente, como classes enum e classes record.

Uma classe selada tem uma lista de permits, que são os únicos subtipos diretos permitidos. Eles devem estar disponíveis no momento em que a classe é compilada, devem ser subtipos da classe selada e devem estar no mesmo módulo que a classe selada (ou no mesmo pacote, se o módulo não tiver nome). Este requisito significa efetivamente que devem ser co-mantidos com a classe selada, que é um requisito razoável para tal acoplamento restrito.

Se os subtipos permitidos forem declarados na mesma unidade de compilação da classe selada, a cláusula de permits pode ser omitida e será inferida como sendo todos os subtipos na mesma unidade de compilação. Uma classe selada não pode ser usada como uma interface funcional para uma expressão lambda ou como o tipo base para uma classe anônima.

Os subtipos de uma classe selada devem ser mais explícitos sobre sua extensibilidade, um subtipo de uma classe selada deve ser sealed, final ou explicitamente marcada como non-sealed. Os records e enums são implicitamente final, portanto, não precisam ser marcados explicitamente como tal. É um erro marcar uma classe ou uma interface como non-sealed se não tiver um supertipo sealed direto.

É uma mudança compatível com o binário e a fonte para tornar uma classe final como sendo sealed. Não é binário nem compatível com a origem selar uma classe não final para a qual ainda não controlamos todas as implementações. É compatível com o binário, mas não com a origem, adicionar novos subtipos permitidos a uma classe selada (isso pode interromper a exaustividade das expressões switch).

Resumo

As classes seladas têm vários usos, são úteis como uma técnica de modelagem de domínio quando faz sentido capturar um conjunto exaustivo de alternativas no modelo de domínio, também são úteis como uma técnica de implementação quando é desejável separar a acessibilidade da extensibilidade. Os tipos de classe seladas são um complemento natural dos records, pois juntos formam um padrão comum conhecido como tipos de dados algébricos, que também são um ajuste natural para o padrão matching, e que chegará em breve ao Java.

Notas de rodapé

1Este exemplo usa uma forma de expressão switch, uma que usa padrões como rótulos de maiúsculas e minúsculas, que ainda não é suportada pela linguagem Java. A cadência de lançamento de seis meses nos permite co-projetar recursos, mas entregá-los de forma independente. Nós esperamos que esse switch seja capaz de usar os padrões como os rótulos case em um futuro próximo.

Sobre o autor

Brian Goetz é um arquiteto da linguagem Java da Oracle e foi o líder de especificação para JSR-335 (Lambda Expressions for the Java Programming Language.) É o autor do best-seller Java Concurrency in Practice e é fascinado por programação desde que Jimmy Carter foi o presidente.

Avalie esse artigo

Relevância
Estilo/Redação

Conteúdo educacional

BT