Por que as vidas explícitas são necessárias no Rust?


199

Eu estava lendo o capítulo de duração do livro Rust e me deparei com este exemplo por uma vida nomeada / explícita:

struct Foo<'a> {
    x: &'a i32,
}

fn main() {
    let x;                    // -+ x goes into scope
                              //  |
    {                         //  |
        let y = &5;           // ---+ y goes into scope
        let f = Foo { x: y }; // ---+ f goes into scope
        x = &f.x;             //  | | error here
    }                         // ---+ f and y go out of scope
                              //  |
    println!("{}", x);        //  |
}                             // -+ x goes out of scope

Está bem claro para mim que o erro que está sendo evitado pelo compilador é o uso após a liberação da referência atribuída a x: depois que o escopo interno é concluído fe , portanto, &f.xtorna - se inválido e não deveria ter sido atribuído x.

Meu problema é que o problema poderia ser facilmente analisado sem o tempo de vida explícito 'a , por exemplo, deduzindo uma atribuição ilegal de uma referência a um escopo mais amplo ( x = &f.x;).

Em quais casos as vidas explícitas são realmente necessárias para evitar erros de uso após liberação (ou de alguma outra classe?)?



2
Para futuros leitores desta pergunta, por favor, note que liga à primeira edição do livro e há agora uma segunda edição :)
carols10cents

Respostas:


205

As outras respostas têm todos pontos importantes ( o exemplo concreto de fjh, onde é necessária uma vida explícita ), mas faltam uma coisa importante: por que são necessárias vidas explícitas quando o compilador diz que você as entendeu errado ?

Esta é realmente a mesma pergunta que "por que tipos explícitos são necessários quando o compilador pode inferir". Um exemplo hipotético:

fn foo() -> _ {  
    ""
}

Obviamente, o compilador pode ver que estou retornando um &'static str, então por que o programador precisa digitá-lo?

O principal motivo é que, embora o compilador possa ver o que seu código faz, ele não sabe qual era sua intenção.

As funções são um limite natural para proteger os efeitos da alteração de código. Se permitirmos que as vidas úteis sejam completamente inspecionadas a partir do código, uma mudança de aparência inocente pode afetar as vidas úteis, o que poderia causar erros em uma função distante. Este não é um exemplo hipotético. Pelo que entendi, Haskell tem esse problema quando você confia na inferência de tipo para funções de nível superior. Rust cortou esse problema em particular pela raiz.

Há também um benefício de eficiência para o compilador - apenas as assinaturas de função precisam ser analisadas para verificar tipos e vida útil. Mais importante, ele tem um benefício de eficiência para o programador. Se não tivemos vidas explícitas, o que essa função faz:

fn foo(a: &u8, b: &u8) -> &u8

É impossível saber sem inspecionar a fonte, o que contraria um grande número de práticas recomendadas de codificação.

inferindo uma atribuição ilegal de uma referência a um escopo mais amplo

Escopos são vidas, essencialmente. Um pouco mais claramente, a vida útil 'aé um parâmetro genérico de vida útil que pode ser especializado com um escopo específico em tempo de compilação, com base no site de chamada.

realmente são necessárias vidas explícitas para evitar [...] erros?

De modo nenhum. É necessário um tempo de vida para evitar erros, mas um tempo de vida explícito é necessário para proteger o pouco que os programadores de sanidade têm.


18
@jco Imagine que você tem alguma função de nível superior f x = x + 1sem uma assinatura de tipo que você está usando em outro módulo. Se você alterar posteriormente a definição para f x = sqrt $ x + 1, seu tipo mudará de Num a => a -> apara Floating a => a -> a, o que causará erros de tipo em todos os locais de chamada onde fé chamado, por exemplo, com um Intargumento. Ter uma assinatura de tipo garante que os erros ocorram localmente.
Fjh

11
“Escopos são vidas úteis, essencialmente. Um pouco mais claramente, uma vida útil 'a é um parâmetro genérico de vida útil que pode ser especializado em um escopo específico no momento da chamada.” Uau, esse é realmente um ponto excelente e esclarecedor. Eu gostaria que fosse incluído no livro explicitamente.
corazza 24/07/2015

2
@fjh Obrigado. Só para ver se entendi bem - o ponto é que, se o tipo fosse explicitamente declarado antes da adição sqrt $, apenas um erro local teria ocorrido após a alteração e não muitos erros em outros lugares (o que é muito melhor se não deseja alterar o tipo real)?
corazza 24/07/2015

5
@jco Exatamente. Não especificar um tipo significa que você pode alterar acidentalmente a interface de uma função. Essa é uma das razões pelas quais é altamente recomendável anotar todos os itens de nível superior em Haskell.
Fjh 24/07/2015

5
Além disso, se uma função recebe duas referências e retorna uma referência, às vezes pode retornar a primeira referência e outras a segunda. Nesse caso, é impossível deduzir uma vida útil para a referência retornada. Vida útil explícita ajuda a evitar / esclarecer tal situação.
22716 MichaelMoser

93

Vamos dar uma olhada no exemplo a seguir.

fn foo<'a, 'b>(x: &'a u32, y: &'b u32) -> &'a u32 {
    x
}

fn main() {
    let x = 12;
    let z: &u32 = {
        let y = 42;
        foo(&x, &y)
    };
}

Aqui, as vidas explícitas são importantes. Isso é compilado porque o resultado de footem a mesma vida útil do primeiro argumento ( 'a), portanto, pode sobreviver ao segundo argumento. Isso é expresso pelos nomes vitalícios na assinatura de foo. Se você alternasse os argumentos na chamada para fooo compilador reclamaria que ynão dura o suficiente:

error[E0597]: `y` does not live long enough
  --> src/main.rs:10:5
   |
9  |         foo(&y, &x)
   |              - borrow occurs here
10 |     };
   |     ^ `y` dropped here while still borrowed
11 | }
   | - borrowed value needs to live until here

16

A anotação de duração na seguinte estrutura:

struct Foo<'a> {
    x: &'a i32,
}

especifica que uma Fooinstância não deve sobreviver à referência que ela contém ( xcampo).

O exemplo que você encontrou no livro Rust não ilustra isso porque fe yvariáveis ​​ficam fora do escopo ao mesmo tempo.

Um exemplo melhor seria o seguinte:

fn main() {
    let f : Foo;
    {
        let n = 5;  // variable that is invalid outside this block
        let y = &n;
        f = Foo { x: y };
    };
    println!("{}", f.x);
}

Agora, frealmente sobrevive à variável apontada por f.x.


9

Observe que não há vida útil explícita nesse trecho de código, exceto a definição da estrutura. O compilador é perfeitamente capaz de inferir vidas úteis main().

Nas definições de tipo, no entanto, as vidas explícitas são inevitáveis. Por exemplo, há uma ambiguidade aqui:

struct RefPair(&u32, &u32);

Devem ter vidas diferentes ou devem ser iguais? Importa da perspectiva do uso, struct RefPair<'a, 'b>(&'a u32, &'b u32)é muito diferente struct RefPair<'a>(&'a u32, &'a u32).

Agora, para casos simples, como o que você forneceu, o compilador poderia teoricamente eliminar vidas como em outros lugares, mas esses casos são muito limitados e não valem complexidade extra no compilador, e esse ganho em clareza estaria no muito menos questionável.


2
Você pode explicar por que eles são muito diferentes?
AB

@AB O segundo requer que ambas as referências compartilhem o mesmo tempo de vida. Isso significa que o refpair.1 não pode viver mais do que o refpair.2 e vice-versa - então os dois refs precisam apontar para algo com o mesmo proprietário. O primeiro, no entanto, exige apenas que o RefPair sobreviva a ambas as partes.
Llogiq

2
@AB, ele é compilado porque as duas vidas são unificadas - porque as vidas locais são menores que 'static, 'staticpodem ser usadas em todos os lugares onde vidas locais podem ser usadas; portanto, no seu exemplo p, seu parâmetro de vida útil será inferido como a vida útil local y.
22415 Vladimir Matveev

5
@AB RefPair<'a>(&'a u32, &'a u32)significa que 'aserá a interseção das duas vidas úteis de entrada, ou seja, neste caso, a vida útil de y.
Fjh 24/07/2015

1
@llogiq "exige que o RefPair sobreviva a ambas as partes"? Eu pensei que era o contrário ... um & u32 ainda pode fazer sentido sem o RefPair, enquanto um RefPair com seus árbitros mortos seria estranho.
qed

6

O caso do livro é muito simples por design. O tópico das vidas úteis é considerado complexo.

O compilador não pode inferir facilmente a vida útil de uma função com vários argumentos.

Além disso, minha própria caixa opcional possui um OptionBooltipo com um as_slicemétodo cuja assinatura é realmente:

fn as_slice(&self) -> &'static [bool] { ... }

Não há absolutamente nenhuma maneira de o compilador ter descoberto isso.


IINM, inferir a vida útil do tipo de retorno de uma função de dois argumentos será equivalente ao problema de parada - IOW, não decidível em um período finito de tempo.
Dstromberg 16/08/19


4

Se uma função recebe duas referências como argumentos e retorna uma referência, a implementação da função às vezes pode retornar a primeira referência e outras a segunda. É impossível prever qual referência será retornada para uma determinada chamada. Nesse caso, é impossível inferir uma vida útil para a referência retornada, pois cada referência de argumento pode se referir a uma ligação de variável diferente com uma vida útil diferente. As vidas explícitas ajudam a evitar ou esclarecer tal situação.

Da mesma forma, se uma estrutura contém duas referências (como dois campos de membro), uma função de membro da estrutura pode às vezes retornar a primeira referência e outras vezes a segunda. Mais uma vez, vidas explícitas impedem essas ambiguidades.

Em algumas situações simples, há elisão da vida em que o compilador pode inferir vidas.


1

A razão pela qual seu exemplo não funciona é simplesmente porque o Rust possui apenas vida útil local e inferência de tipo. O que você está sugerindo exige inferência global. Sempre que você tiver uma referência cujo tempo de vida não possa ser eliminado, ela deverá ser anotada.


1

Como recém-chegado ao Rust, meu entendimento é que vidas explícitas servem a dois propósitos.

  1. Colocar uma anotação de duração explícita em uma função restringe o tipo de código que pode aparecer dentro dessa função. A vida útil explícita permite ao compilador garantir que seu programa esteja fazendo o que você pretendia.

  2. Se você (o compilador) deseja verificar se um trecho de código é válido, você (o compilador) não precisará procurar iterativamente todas as funções chamadas. Basta dar uma olhada nas anotações de funções chamadas diretamente por esse trecho de código. Isso torna seu programa muito mais fácil de raciocinar para você (o compilador) e torna os tempos de compilação gerenciáveis.

No ponto 1., considere o seguinte programa escrito em Python:

import pandas as pd
import numpy as np

def second_row(ar):
    return ar[0]

def work(second):
    df = pd.DataFrame(data=second)
    df.loc[0, 0] = 1

def main():
    # .. load data ..
    ar = np.array([[0, 0], [0, 0]])

    # .. do some work on second row ..
    second = second_row(ar)
    work(second)

    # .. much later ..
    print(repr(ar))

if __name__=="__main__":
    main()

que imprimirá

array([[1, 0],
       [0, 0]])

Esse tipo de comportamento sempre me surpreende. O que está acontecendo é que o dfcompartilhamento de memória é feito ar; portanto, quando parte do conteúdo das dfalterações é workafetada artambém. No entanto, em alguns casos, isso pode ser exatamente o que você deseja, por motivos de eficiência de memória (sem cópia). O verdadeiro problema nesse código é que a função second_rowestá retornando a primeira linha em vez da segunda; boa sorte depurando isso.

Considere um programa semelhante escrito em Rust:

#[derive(Debug)]
struct Array<'a, 'b>(&'a mut [i32], &'b mut [i32]);

impl<'a, 'b> Array<'a, 'b> {
    fn second_row(&mut self) -> &mut &'b mut [i32] {
        &mut self.0
    }
}

fn work(second: &mut [i32]) {
    second[0] = 1;
}

fn main() {
    // .. load data ..
    let ar1 = &mut [0, 0][..];
    let ar2 = &mut [0, 0][..];
    let mut ar = Array(ar1, ar2);

    // .. do some work on second row ..
    {
        let second = ar.second_row();
        work(second);
    }

    // .. much later ..
    println!("{:?}", ar);
}

Compilando isso, você obtém

error[E0308]: mismatched types
 --> src/main.rs:6:13
  |
6 |             &mut self.0
  |             ^^^^^^^^^^^ lifetime mismatch
  |
  = note: expected type `&mut &'b mut [i32]`
             found type `&mut &'a mut [i32]`
note: the lifetime 'b as defined on the impl at 4:5...
 --> src/main.rs:4:5
  |
4 |     impl<'a, 'b> Array<'a, 'b> {
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^
note: ...does not necessarily outlive the lifetime 'a as defined on the impl at 4:5
 --> src/main.rs:4:5
  |
4 |     impl<'a, 'b> Array<'a, 'b> {
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^

Na verdade, você recebe dois erros, há também um com os papéis de 'ae 'bintercambiado. Observando a anotação de second_row, descobrimos que a saída deve ser &mut &'b mut [i32], ou seja, a saída deve ser uma referência a uma referência com vida útil 'b(a vida útil da segunda linha de Array). No entanto, como estamos retornando a primeira linha (que possui vida útil 'a), o compilador reclama da incompatibilidade da vida útil. No lugar certo. No tempo certo. Depurar é fácil.


0

Penso em uma anotação vitalícia como um contrato sobre uma determinada referência válida no escopo de recebimento apenas enquanto permanecer válida no escopo de origem. Declarar mais referências no mesmo período de vida mescla os escopos, o que significa que todas as referências de origem precisam satisfazer esse contrato. Essa anotação permite que o compilador verifique o cumprimento do contrato.

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.