O final está mal definido?


186

Primeiro, um quebra-cabeça: o que o código a seguir imprime?

public class RecursiveStatic {
    public static void main(String[] args) {
        System.out.println(scale(5));
    }

    private static final long X = scale(10);

    private static long scale(long value) {
        return X * value;
    }
}

Responda:

0 0

Spoilers abaixo.


Se você imprimir Xem escala (longa) e redefinir X = scale(10) + 3, as impressões serão X = 0então X = 3. Isso significa que Xestá temporariamente definido como 0e posteriormente definido como 3. Isso é uma violação de final!

O modificador estático, em combinação com o modificador final, também é usado para definir constantes. O modificador final indica que o valor desse campo não pode ser alterado .

Fonte: https://docs.oracle.com/javase/tutorial/java/javaOO/classvars.html [ênfase adicionada]


Minha pergunta: isso é um bug? Está finalmal definido?


Aqui está o código no qual estou interessado. XRecebe dois valores diferentes: 0e 3. Eu acredito que isso seja uma violação de final.

public class RecursiveStatic {
    public static void main(String[] args) {
        System.out.println(scale(5));
    }

    private static final long X = scale(10) + 3;

    private static long scale(long value) {
        System.out.println("X = " + X);
        return X * value;
    }
}

Esta pergunta foi sinalizada como uma possível duplicata da ordem de inicialização do campo final estático de Java . Acredito que esta questão não seja uma duplicata, pois a outra pergunta aborda a ordem de inicialização, enquanto minha pergunta aborda uma inicialização cíclica combinada com a finaltag. Apenas a partir da outra pergunta, não seria possível entender por que o código na minha pergunta não comete um erro.

Isso é especialmente claro ao observar a saída que o ernesto obtém: quando aé marcado com final, ele obtém a seguinte saída:

a=5
a=5

que não envolve a parte principal da minha pergunta: como uma finalvariável altera sua variável?


17
Essa maneira de referenciar o Xmembro é como se referir a um membro da subclasse antes que o construtor da superclasse termine, esse é seu problema e não a definição de final.
Daniu 19/0318

4
De JLS:A blank final instance variable must be definitely assigned (§16.9) at the end of every constructor (§8.8) of the class in which it is declared; otherwise a compile-time error occurs.
Ivan

1
@ Ivan, não se trata de constante, mas de variável de instância. Mas você pode adicionar o capítulo?
AxelH

9
Apenas como uma observação: nunca faça nada disso no código de produção. É super confuso para todos se alguém começar a explorar brechas no JLS.
Zabuzard

13
Para sua informação, você também pode criar exatamente a mesma situação em C #. O C # promete que os loops nas declarações constantes serão capturados no tempo de compilação, mas não faz essas promessas sobre declarações somente leitura e, na prática, você pode entrar em situações em que o valor zero inicial do campo é observado por outro inicializador de campo. Se dói quando você faz isso, não faça . O compilador não salvará você.
Eric Lippert

Respostas:


217

Um achado muito interessante. Para entendê-lo, precisamos procurar na Java Language Specification ( JLS ).

O motivo é que finalapenas permite uma tarefa . O valor padrão, no entanto, não é uma atribuição . De fato, todas essas variáveis ( variável de classe, variável de instância, componente de matriz) apontam para seu valor padrão desde o início, antes das atribuições . A primeira atribuição altera a referência.


Variáveis ​​de classe e valor padrão

Veja o seguinte exemplo:

private static Object x;

public static void main(String[] args) {
    System.out.println(x); // Prints 'null'
}

Não atribuímos explicitamente um valor x, embora aponte para null, seu valor padrão. Compare isso com §4.12.5 :

Valores Iniciais das Variáveis

Cada variável de classe , variável de instância ou componente de matriz é inicializado com um valor padrão quando é criado ( §15.9 , §15.10.2 )

Observe que isso vale apenas para esse tipo de variáveis, como no nosso exemplo. Não é válido para variáveis ​​locais, consulte o seguinte exemplo:

public static void main(String[] args) {
    Object x;
    System.out.println(x);
    // Compile-time error:
    // variable x might not have been initialized
}

Do mesmo parágrafo JLS:

Uma variável local ( §14.4 , §14.14 ) deve receber explicitamente um valor antes de ser usada, tanto pela inicialização ( §14.4 ) quanto pela atribuição ( §15.26 ), de forma que possa ser verificada usando as regras para atribuição definida ( § 16 (Cessão Definida) ).


Variáveis ​​finais

Agora vamos dar uma olhada final, de §4.12.4 :

Variáveis finais

Uma variável pode ser declarada final . Uma variável final pode ser atribuída apenas uma vez . É um erro em tempo de compilação se uma variável final for atribuída, a menos que seja definitivamente não atribuída imediatamente antes da atribuição ( §16 (Atribuição Definida) ).


Explicação

Agora voltando ao seu exemplo, ligeiramente modificado:

public static void main(String[] args) {
    System.out.println("After: " + X);
}

private static final long X = assign();

private static long assign() {
    // Access the value before first assignment
    System.out.println("Before: " + X);

    return X + 1;
}

Produz

Before: 0
After: 1

Lembre-se do que aprendemos. Dentro do método, assigna variável Xainda não foi atribuída a um valor. Portanto, aponta para seu valor padrão, pois é uma variável de classe e, de acordo com o JLS, essas variáveis ​​sempre apontam imediatamente para seus valores padrão (em contraste com as variáveis ​​locais). Após o assignmétodo, a variável Xrecebe o valor 1e, por finalisso, não podemos mais alterá-lo. Portanto, o seguinte não funcionaria devido a final:

private static long assign() {
    // Assign X
    X = 1;

    // Second assign after method will crash
    return X + 1;
}

Exemplo no JLS

Graças a @ Andrew, eu encontrei um parágrafo JLS que cobre exatamente esse cenário, ele também o demonstra.

Mas primeiro vamos dar uma olhada

private static final long X = X + 1;
// Compile-time error:
// self-reference in initializer

Por que isso não é permitido, enquanto o acesso do método é? Dê uma olhada no §8.3.3, que fala sobre quando o acesso aos campos é restrito se o campo ainda não foi inicializado.

Ele lista algumas regras relevantes para variáveis ​​de classe:

Para uma referência pelo nome simples a uma variável de classe fdeclarada na classe ou na interface C, é um erro em tempo de compilação se :

  • A referência aparece em um inicializador de variável de classe Cou em um inicializador estático de C( §8.7 ); e

  • A referência aparece no inicializador do fpróprio declarador ou em um ponto à esquerda do fdeclarador; e

  • A referência não está no lado esquerdo de uma expressão de atribuição ( §15.26 ); e

  • A classe ou interface mais interna que encerra a referência é C.

É simples, o X = X + 1é pego por essas regras, o método não acessa. Eles até listam esse cenário e dão um exemplo:

Os acessos por métodos não são verificados dessa maneira, portanto:

class Z {
    static int peek() { return j; }
    static int i = peek();
    static int j = 1;
}
class Test {
    public static void main(String[] args) {
        System.out.println(Z.i);
    }
}

produz a saída:

0

porque o inicializador de variáveis ​​para iusa o método de classe peek para acessar o valor da variável jantes de jter sido inicializado pelo inicializador de variáveis; nesse momento, ele ainda possui seu valor padrão ( §4.12.5 ).


1
@ Andrew Sim, variável de classe, obrigado. Sim, ele iria funcionar se não haveria algumas-regras extras que restringem tal acesso: §8.3.3 . Dê uma olhada nos quatro pontos especificados para variáveis ​​de classe (a primeira entrada). A abordagem do método no exemplo dos OPs não é capturada por essas regras, portanto, podemos acessar a Xpartir do método. Eu não me importaria muito. Depende apenas de como exatamente o JLS define as coisas para trabalhar em detalhes. Eu nunca usaria código assim, apenas explorando algumas regras no JLS.
Zabuzard 19/03/19

4
O problema é que você pode chamar métodos de instância do construtor, algo que provavelmente não deveria ter sido permitido. Por outro lado, não é permitido atribuir locais antes de chamar de super, o que seria útil e seguro. Vai saber.
Reintegrar Monica

1
@ Andrew, você provavelmente é o único aqui mencionado de fatoforwards references (que também faz parte do JLS). Isto é tão simples sem este looong resposta stackoverflow.com/a/49371279/1059372
Eugene

1
"A primeira tarefa muda a referência." Nesse caso, não é um tipo de referência, mas um tipo primitivo.
Fabian

1
Esta resposta está certa, se um pouco longa. :-) Acho que o tl; dr é que o OP citou um tutorial que dizia que "[um campo final] não pode mudar", não o JLS. Embora os tutoriais da Oracle sejam muito bons, eles não cobrem todos os casos extremos. Para a pergunta do OP, precisamos ir para a definição real de JLS final - e essa definição não afirma (que o OP legitimamente desafia) que o valor de um campo final nunca pode mudar.
yshavit

23

Nada a ver com final aqui.

Como está no nível da instância ou da classe, ele mantém o valor padrão se nada for atribuído ainda. Essa é a razão que você vê 0ao acessá-lo sem atribuir.

Se você acessar Xsem atribuir completamente, ele manterá os valores padrão de long, ou seja 0, daí os resultados.


3
O que é complicado nisso é que, se você não atribuir o valor, ele não será atribuído com o valor padrão, mas se você o usou para atribuir a si mesmo o valor "final", ele será ...
AxelH

2
@ AxelH Entendo o que você quer dizer com isso. Mas é assim que deve funcionar, caso contrário, o mundo entra em colapso;).
precisa

20

Não é um bug.

Quando a primeira chamada para scaleé chamada de

private static final long X = scale(10);

Ele tenta avaliar return X * value. Xainda não recebeu um valor e, portanto, o valor padrão para a longé usado (o que é 0).

Portanto, essa linha de código é avaliada como X * 10ie 0 * 10qual é 0.


8
Eu não acho que é isso que OP confunde. O que confunde é X = scale(10) + 3. Desde X, quando referenciada a partir do método, é 0. Mas depois é 3. Portanto, o OP pensa que Xsão atribuídos dois valores diferentes, com os quais entrariam em conflito final.
Zabuzard

4
@Zabuza não está presente explicar com a " Ele tenta avaliar return X * value. XNão foi atribuído um valor ainda e, portanto, assume o valor padrão para um longque é 0. "? Não se diz que Xé atribuído com o valor padrão, mas que ele Xé "substituído" (não cite esse termo;)) pelo valor padrão.
AxelH

14

Não é um bug, simplesmente, não é uma forma ilegal de referência direta, nada mais.

String x = y;
String y = "a"; // this will not compile 


String x = getIt(); // this will compile, but will be null
String y = "a";

public String getIt(){
    return y;
}

É simplesmente permitido pela Especificação.

Para dar o seu exemplo, é exatamente aqui que isso corresponde:

private static final long X = scale(10) + 3;

Você está fazendo uma referência direta a scaleisso que não é ilegal, como dito anteriormente, mas permite que você obtenha o valor padrão de X. Novamente, isso é permitido pelo Spec (para ser mais exato, não é proibido), portanto funciona muito bem


boa resposta! Estou apenas curioso sobre por que a especificação permite que o segundo caso seja compilado. É a única maneira de ver o estado "inconsistente" de um campo final?
Andrew Tobilko 19/03/19

@ Andrew isso tem me incomodado por muito muito tempo também, eu estou inclinado a pensar a C ++ ou C faz isso (não idéia se isso é verdade)
Eugene

@ Andrew: Porque fazer o contrário seria resolver o teorema da incompletude de Turing.
Joshua

9
@ Josué: Eu acho que você está misturando vários conceitos diferentes aqui: (1) o problema de parada, (2) o problema de decisão, (3) teorema da incompletude de Godel e (4) linguagens de programação completas de Turing. Os escritores do compilador não tentam resolver o problema "esta variável está definitivamente definida antes de ser usada?" perfeitamente, porque esse problema é equivalente a resolver o problema da parada, e sabemos que não podemos fazê-lo.
Eric Lippert

4
@EricLippert: Haha oops. Turing incompletude e parar o problema ocupam o mesmo lugar em minha mente.
Joshua

4

Os membros de nível de classe podem ser inicializados no código dentro da definição de classe. O bytecode compilado não pode inicializar os membros da classe em linha. (Os membros da instância são tratados da mesma forma, mas isso não é relevante para a pergunta fornecida.)

Quando alguém escreve algo como o seguinte:

public class Demo1 {
    private static final long DemoLong1 = 1000;
}

O bytecode gerado seria semelhante ao seguinte:

public class Demo2 {
    private static final long DemoLong2;

    static {
        DemoLong2 = 1000;
    }
}

O código de inicialização é colocado em um inicializador estático, que é executado quando o carregador de classes carrega a classe pela primeira vez. Com esse conhecimento, sua amostra original seria semelhante à seguinte:

public class RecursiveStatic {
    private static final long X;

    private static long scale(long value) {
        return X * value;
    }

    static {
        X = scale(10);
    }

    public static void main(String[] args) {
        System.out.println(scale(5));
    }
}
  1. A JVM carrega o RecursiveStatic como o ponto de entrada do jar.
  2. O carregador de classes executa o inicializador estático quando a definição de classe é carregada.
  3. O inicializador chama a função scale(10)para atribuir o static finalcampo X.
  4. A scale(long)função é executada enquanto a classe é parcialmente inicializada, lendo o valor não inicializado Xcujo padrão é long ou 0.
  5. O valor de 0 * 10é atribuído Xe o carregador de classes é concluído.
  6. A JVM executa a chamada pública do método principal static static, scale(5)que multiplica 5 pelo Xvalor agora inicializado de 0 retornando 0.

O campo final estático Xé atribuído apenas uma vez, preservando a garantia mantida pela finalpalavra - chave. Para a consulta subsequente da adição de 3 na atribuição, a etapa 5 acima torna-se a avaliação de 0 * 10 + 3qual é o valor 3e o método principal imprimirá o resultado desse 3 * 5valor 15.


3

A leitura de um campo não inicializado de um objeto deve resultar em um erro de compilação. Infelizmente para Java, isso não acontece.

Eu acho que a razão fundamental pela qual esse é o caso está "escondida" profundamente na definição de como os objetos são instanciados e construídos, embora eu não conheça os detalhes do padrão.

Em certo sentido, final é mal definido, porque nem mesmo cumpre o que seu objetivo declarado é devido a esse problema. No entanto, se todas as suas aulas forem escritas corretamente, você não terá esse problema. Significando que todos os campos são sempre definidos em todos os construtores e nenhum objeto é criado sem chamar um de seus construtores. Isso parece natural até que você precise usar uma biblioteca de serialização.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.