Por que um parâmetro de tipo é mais forte que um parâmetro de método

12

Porque é

public <R, F extends Function<T, R>> Builder<T> withX(F getter, R returnValue) {...}

mais rigoroso então

public <R> Builder<T> with(Function<T, R> getter, R returnValue) {...}

Este é um acompanhamento de Por que o tipo de retorno lambda não é verificado no momento da compilação . Eu descobri usando o método withX()como

.withX(MyInterface::getLength, "I am not a Long")

produz o erro de tempo de compilação desejado:

O tipo de getLength () do tipo BuilderExample.MyInterface é longo, isso é incompatível com o tipo de retorno do descritor: String

enquanto estiver usando o método with()não.

exemplo completo:

import java.util.function.Function;

public class SO58376589 {
  public static class Builder<T> {
    public <R, F extends Function<T, R>> Builder<T> withX(F getter, R returnValue) {
      return this;
    }

    public <R> Builder<T> with(Function<T, R> getter, R returnValue) {
      return this;
    }

  }

  static interface MyInterface {
    public Long getLength();
  }

  public static void main(String[] args) {
    Builder<MyInterface> b = new Builder<MyInterface>();
    Function<MyInterface, Long> getter = MyInterface::getLength;
    b.with(getter, 2L);
    b.with(MyInterface::getLength, 2L);
    b.withX(getter, 2L);
    b.withX(MyInterface::getLength, 2L);
    b.with(getter, "No NUMBER"); // error
    b.with(MyInterface::getLength, "No NUMBER"); // NO ERROR !!
    b.withX(getter, "No NUMBER"); // error
    b.withX(MyInterface::getLength, "No NUMBER"); // error !!!
  }
}

javac SO58376589.java

SO58376589.java:32: error: method with in class Builder<T> cannot be applied to given types;
    b.with(getter, "No NUMBER"); // error
     ^
  required: Function<MyInterface,R>,R
  found: Function<MyInterface,Long>,String
  reason: inference variable R has incompatible bounds
    equality constraints: Long
    lower bounds: String
  where R,T are type-variables:
    R extends Object declared in method <R>with(Function<T,R>,R)
    T extends Object declared in class Builder
SO58376589.java:34: error: method withX in class Builder<T> cannot be applied to given types;
    b.withX(getter, "No NUMBER"); // error
     ^
  required: F,R
  found: Function<MyInterface,Long>,String
  reason: inference variable R has incompatible bounds
    equality constraints: Long
    lower bounds: String
  where F,R,T are type-variables:
    F extends Function<MyInterface,R> declared in method <R,F>withX(F,R)
    R extends Object declared in method <R,F>withX(F,R)
    T extends Object declared in class Builder
SO58376589.java:35: error: incompatible types: cannot infer type-variable(s) R,F
    b.withX(MyInterface::getLength, "No NUMBER"); // error
           ^
    (argument mismatch; bad return type in method reference
      Long cannot be converted to String)
  where R,F,T are type-variables:
    R extends Object declared in method <R,F>withX(F,R)
    F extends Function<T,R> declared in method <R,F>withX(F,R)
    T extends Object declared in class Builder
3 errors

Exemplo estendido

O exemplo a seguir mostra o comportamento diferente do método e do parâmetro de tipo resumido em um Fornecedor. Além disso, mostra a diferença no comportamento do consumidor para um parâmetro de tipo. E mostra que não faz diferença se é um consumidor ou fornecedor para um parâmetro de método.

import java.util.function.Consumer;
import java.util.function.Supplier;
interface TypeInference {

  Number getNumber();

  void setNumber(Number n);

  @FunctionalInterface
  interface Method<R> {
    TypeInference be(R r);
  }

  //Supplier:
  <R> R letBe(Supplier<R> supplier, R value);
  <R, F extends Supplier<R>> R letBeX(F supplier, R value);
  <R> Method<R> let(Supplier<R> supplier);  // return (x) -> this;

  //Consumer:
  <R> R lettBe(Consumer<R> supplier, R value);
  <R, F extends Consumer<R>> R lettBeX(F supplier, R value);
  <R> Method<R> lett(Consumer<R> consumer);


  public static void main(TypeInference t) {
    t.letBe(t::getNumber, (Number) 2); // Compiles :-)
    t.lettBe(t::setNumber, (Number) 2); // Compiles :-)
    t.letBe(t::getNumber, 2); // Compiles :-)
    t.lettBe(t::setNumber, 2); // Compiles :-)
    t.letBe(t::getNumber, "NaN"); // !!!! Compiles :-(
    t.lettBe(t::setNumber, "NaN"); // Does not compile :-)

    t.letBeX(t::getNumber, (Number) 2); // Compiles :-)
    t.lettBeX(t::setNumber, (Number) 2); // Compiles :-)
    t.letBeX(t::getNumber, 2); // !!! Does not compile  :-(
    t.lettBeX(t::setNumber, 2); // Compiles :-)
    t.letBeX(t::getNumber, "NaN"); // Does not compile :-)
    t.lettBeX(t::setNumber, "NaN"); // Does not compile :-)

    t.let(t::getNumber).be(2); // Compiles :-)
    t.lett(t::setNumber).be(2); // Compiles :-)
    t.let(t::getNumber).be("NaN"); // Does not compile :-)
    t.lett(t::setNumber).be("NaN"); // Does not compile :-)
  }
}
jukzi
fonte
11
Por causa da inferência com o último. Embora ambos sejam baseados no caso de uso que é necessário implementar. Para o seu, o primeiro pode ser rigoroso e bom. Para flexibilidade, alguém pode preferir o último.
Naman
Você está tentando compilar isso no Eclipse? A procura de cadeias de erro do formato que você colou sugere que este é um erro específico do Eclipse (ecj). Você tem o mesmo problema ao compilar com javacuma ferramenta bruta ou uma compilação como Gradle ou Maven?
user31601
@ user31601 adicionei um exemplo completo com a saída javac. As mensagens de erro são pouco diferentes formatada mas ainda eclipse e javac espera o mesmo comportamento
jukzi

Respostas:

12

Esta é uma pergunta realmente interessante. Receio que a resposta seja complicada.

tl; dr

Descobrir a diferença envolve uma leitura bastante aprofundada da especificação de inferência de tipo do Java , mas basicamente se resume a isso:

  • Todas as outras coisas iguais, o compilador infere o tipo mais específico possível.
  • No entanto, se ele puder encontrar uma substituição para um parâmetro de tipo que atenda a todos os requisitos, a compilação será bem-sucedida, por mais vaga que seja a substituição.
  • Pois withexiste uma substituição (reconhecidamente vaga) que satisfaz todos os requisitos em R:Serializable
  • Pois withX, a introdução do parâmetro de tipo adicional Fforça o compilador a resolver Rprimeiro, sem considerar a restrição F extends Function<T,R>. Rresolve para (muito mais específico), o Stringque significa que a inferência de Ffalhas.

Este último ponto é o mais importante, mas também o mais ondulado. Não consigo pensar em uma maneira melhor e concisa de redigir, portanto, se você quiser mais detalhes, sugiro que leia a explicação completa abaixo.

Esse comportamento é pretendido?

Vou sair do ramo aqui e dizer não .

Não estou sugerindo que haja um bug na especificação, mais do que (no caso withX) os designers de linguagem levantaram as mãos e disseram "há algumas situações em que a inferência de tipos fica muito difícil, então apenas falhamos" . Embora o comportamento do compilador em relação a withXpareça ser o que você deseja, consideraria esse um efeito colateral incidental da especificação atual, em vez de uma decisão de design com intenção positiva.

Isso é importante, porque informa a pergunta Devo confiar nesse comportamento no design do meu aplicativo? Eu argumentaria que você não deveria, porque você não pode garantir que versões futuras do idioma continuem a se comportar dessa maneira.

Embora seja verdade que os designers de linguagem se esforçam muito para não quebrar os aplicativos existentes quando atualizam suas especificações / design / compilador, o problema é que o comportamento no qual você deseja confiar é aquele em que o compilador falha atualmente (ou seja, não é um aplicativo existente ). As atualizações do Langauge transformam o código de não compilação em código de compilação o tempo todo. Por exemplo, o código a seguir pode ser garantido para não compilar no Java 7, mas seria compilado no Java 8:

static Runnable x = () -> System.out.println();

Seu caso de uso não é diferente.

Outro motivo pelo qual eu seria cauteloso ao usar seu withXmétodo é o Fpróprio parâmetro. Geralmente, existe um parâmetro de tipo genérico em um método (que não aparece no tipo de retorno) para vincular os tipos de várias partes da assinatura. Está dizendo:

Não me importo com o que Té, mas quero ter certeza de que, onde quer que eu use T, é do mesmo tipo.

Logicamente, então, esperamos que cada parâmetro de tipo apareça pelo menos duas vezes em uma assinatura de método, caso contrário "não está fazendo nada". Fem sua withXsó aparece uma vez na assinatura, o que me sugere a utilização de um parâmetro de tipo não em linha com a intenção deste recurso da língua.

Uma implementação alternativa

Uma maneira de implementar isso de uma maneira um pouco mais "comportamental" seria dividir seu withmétodo em uma cadeia de 2:

public class Builder<T> {

    public final class With<R> {
        private final Function<T,R> method;

        private With(Function<T,R> method) {
            this.method = method;
        }

        public Builder<T> of(R value) {
            // TODO: Body of your old 'with' method goes here
            return Builder.this;
        }
    }

    public <R> With<R> with(Function<T,R> method) {
        return new With<>(method);
    }

}

Isso pode ser usado da seguinte maneira:

b.with(MyInterface::getLong).of(1L); // Compiles
b.with(MyInterface::getLong).of("Not a long"); // Compiler error

Isso não inclui um parâmetro de tipo estranho como o seu withX. Ao dividir o método em duas assinaturas, ele também expressa melhor a intenção do que você está tentando fazer, do ponto de vista da segurança de tipo:

  • O primeiro método configura uma classe ( With) que define o tipo com base na referência do método.
  • O método scond ( of) restringe o tipo de valuepara ser compatível com o que você configurou anteriormente.

A única maneira de uma versão futura da linguagem conseguir compilar isso é se a digitação completa for implementada, o que parece improvável.

Uma observação final para tornar tudo irrelevante: acho que o Mockito (e em particular sua funcionalidade de stubbing) pode já fazer o que você está tentando alcançar com o seu "construtor genérico seguro de tipo". Talvez você possa usar isso em vez disso?

A explicação completa (ish)

Vou trabalhar no procedimento de inferência de tipo para ambos withe withX. Isso é bastante longo, então vá devagar. Apesar de longo, ainda deixei muitos detalhes de fora. Você pode consultar a especificação para obter mais detalhes (siga os links) para se convencer de que estou certo (posso ter cometido um erro).

Além disso, para simplificar um pouco as coisas, vou usar um exemplo de código mais mínimo. A principal diferença é que ele alterna para fora Functionpara Supplier, por isso há menos tipos e parâmetros em jogo. Aqui está um trecho completo que reproduz o comportamento que você descreveu:

public class TypeInference {

    static long getLong() { return 1L; }

    static <R> void with(Supplier<R> supplier, R value) {}
    static <R, F extends Supplier<R>> void withX(F supplier, R value) {}

    public static void main(String[] args) {
        with(TypeInference::getLong, "Not a long");       // Compiles
        withX(TypeInference::getLong, "Also not a long"); // Does not compile
    }

}

Vamos trabalhar com o procedimento de inferência de aplicabilidade e procedimento de inferência de tipo para cada invocação de método, por sua vez:

with

Nós temos:

with(TypeInference::getLong, "Not a long");

O conjunto de limites inicial, B 0 , é:

  • R <: Object

Todas as expressões de parâmetro são pertinentes à aplicabilidade .

Portanto, a restrição inicial definida para a inferência de aplicabilidade , C , é:

  • TypeInference::getLong é compatível com Supplier<R>
  • "Not a long" é compatível com R

Isso reduz ao conjunto limitado B 2 de:

  • R <: Object(de B 0 )
  • Long <: R (da primeira restrição)
  • String <: R (a partir da segunda restrição)

Como isso não contém o limite ' false ' e (presumo) a resolução de Rêxito (doação Serializable), a invocação é aplicável.

Então, passamos à inferência do tipo de chamada .

O novo conjunto de restrições, C , com variáveis ​​de entrada e saída associadas , é:

  • TypeInference::getLong é compatível com Supplier<R>
    • Variáveis ​​de entrada: nenhuma
    • Variáveis ​​de saída: R

Isso não contém interdependências entre as variáveis ​​de entrada e saída , portanto, pode ser reduzido em uma única etapa, e o conjunto de limites final, B 4 , é o mesmo que B 2 . Portanto, a resolução é bem-sucedida como antes, e o compilador dá um suspiro de alívio!

withX

Nós temos:

withX(TypeInference::getLong, "Also not a long");

O conjunto de limites inicial, B 0 , é:

  • R <: Object
  • F <: Supplier<R>

Somente a segunda expressão de parâmetro é pertinente à aplicabilidade . O primeiro ( TypeInference::getLong) não é, porque atende à seguinte condição:

Se mé um método genérico e a chamada de método não fornece argumentos de tipo explícitas, uma expressão lambda explicitamente digitado ou uma expressão de referência método exacto para o qual o tipo de alvo correspondente (como derivado a partir da assinatura de m) é um parâmetro de tipo m.

Portanto, a restrição inicial definida para a inferência de aplicabilidade , C , é:

  • "Also not a long" é compatível com R

Isso reduz ao conjunto limitado B 2 de:

  • R <: Object(de B 0 )
  • F <: Supplier<R>(de B 0 )
  • String <: R (da restrição)

Novamente, como isso não contém o limite ' false ' e a resolução de Rêxito (doação String), a invocação é aplicável.

Inferência do tipo de chamada mais uma vez ...

Desta vez, o novo conjunto de restrições, C , com variáveis ​​de entrada e saída associadas , é:

  • TypeInference::getLong é compatível com F
    • Variáveis ​​de entrada: F
    • Variáveis ​​de saída: nenhuma

Novamente, não temos interdependências entre variáveis ​​de entrada e saída . No entanto, desta vez, não é uma variável de entrada ( F), por isso temos de resolver isso antes de tentar a redução . Então, começamos com nosso conjunto vinculado B 2 .

  1. Determinamos um subconjunto da Vseguinte maneira:

    Dado um conjunto de variáveis ​​de inferência a serem resolvidas, Vseja a união desse conjunto e todas as variáveis ​​das quais depende a resolução de pelo menos uma variável neste conjunto.

    Pelo segundo ligado em B 2 , a resolução de Fdepende R, por isso V := {F, R}.

  2. Escolhemos um subconjunto de Vacordo com a regra:

    deixar { α1, ..., αn }ser um subconjunto não vazio de variáveis não instanciadas em Vtal que i) para todos i (1 ≤ i ≤ n), se αidepende da resolução de uma variável β, em seguida, quer βtem uma instanciação ou existe alguma jtal que β = αj; e ii) não existe um subconjunto apropriado não vazio de { α1, ..., αn }com essa propriedade.

    O único subconjunto Vque satisfaz essa propriedade é {R}.

  3. Usando o terceiro bound ( String <: R), instanciamos R = Stringe incorporamos isso em nosso conjunto de bound. Ragora está resolvido e o segundo limite se torna efetivamente F <: Supplier<String>.

  4. Usando o segundo limite (revisado), instanciamos F = Supplier<String>. Fagora está resolvido.

Agora que Festá resolvido, podemos prosseguir com a redução , usando a nova restrição:

  1. TypeInference::getLong é compatível com Supplier<String>
  2. ... reduz a Long é compatível com String
  3. ... que reduz a falso

... e recebemos um erro do compilador!


Notas adicionais sobre o 'Exemplo estendido'

O exemplo estendido da pergunta examina alguns casos interessantes que não são diretamente abordados pelos trabalhos acima:

  • Onde o tipo de valor é um subtipo do método return type ( Integer <: Number)
  • Onde a interface funcional é contravariante no tipo inferido (ou seja, em Consumervez de Supplier)

Em particular, 3 das invocações fornecidas se destacam como potencialmente sugerindo um comportamento do compilador 'diferente' ao descrito nas explicações:

t.lettBe(t::setNumber, "NaN"); // Does not compile :-)

t.letBeX(t::getNumber, 2); // !!! Does not compile  :-(
t.lettBeX(t::setNumber, 2); // Compiles :-)

O segundo desses 3 passará exatamente pelo mesmo processo de inferência que o withXanterior (basta substituir Longpor Numbere Stringcom Integer). Isso ilustra outro motivo pelo qual você não deve confiar nesse comportamento de inferência de tipo com falha para o design de sua classe, pois a falha na compilação aqui provavelmente não é um comportamento desejável.

Para os outros 2 (e, de fato, qualquer uma das outras invocações que envolvam uma que Consumervocê deseja trabalhar), o comportamento deve ser aparente se você trabalhar com o procedimento de inferência de tipo estabelecido para um dos métodos acima (ou seja, withpela primeira vez, withXpela terceiro). Há apenas uma pequena alteração que você precisa observar:

  • A restrição no primeiro parâmetro ( t::setNumber é compatível com Consumer<R> ) será reduzida para, em R <: Numbervez de Number <: Rcomo para Supplier<R>. Isso está descrito na documentação vinculada sobre redução.

Deixo como exercício para o leitor trabalhar com cuidado um dos procedimentos acima, munido desse conhecimento adicional, para demonstrar a si mesmos exatamente por que uma chamada específica é compilada ou não.

user31601
fonte
Muito aprofundado, bem pesquisado e formulado. Obrigado!
Zabuzard 16/10/19
@ user31601 Você pode indicar onde a diferença entre Fornecedor e Consumidor entra em jogo. Eu adicionei um exemplo estendido na pergunta original para isso. Ele mostra comportamento covariante, contravariante e invariável para as diferentes versões de letBe (), letBeX () e let (). Be (), dependendo do fornecedor / consumidor.
Jukzi #
@jukzi Adicionei algumas notas adicionais, mas você deve ter informações suficientes para trabalhar com esses novos exemplos.
user31601
Isso é interessante: tantos casos especiais em 18.2.1. para lambdas e referências de métodos em que eu não esperaria nenhum caso especial para eles do meu ingênuo entendimento. E provavelmente nenhum desenvolvedor comum esperaria.
Jukzi 17/10/19
Bem, acho que o motivo é que, com as referências lambdas e métodos, o compilador precisa decidir que tipo apropriado o lambda deve implementar - ele precisa fazer uma escolha! Por exemplo, TypeInference::getLongpoderia implementar Supplier<Long>ou Supplier<Serializable>ou Supplier<Number>etc, mas crucialmente ele pode implementar apenas um deles (como qualquer outra classe)! Isso é diferente de todas as outras expressões, onde os tipos implementados são todos conhecidos antecipadamente, e o compilador apenas precisa descobrir se um deles atende aos requisitos de restrição.
user31601