Receptor de valor vs. receptor de ponteiro

108

Não está claro para mim em que caso eu gostaria de usar um receptor de valor em vez de sempre usar um receptor de ponteiro.
Para recapitular os documentos:

type T struct {
    a int
}
func (tv  T) Mv(a int) int         { return 0 }  // value receiver
func (tp *T) Mp(f float32) float32 { return 1 }  // pointer receiver

A documentação também diz "Para tipos como tipos básicos, fatias e pequenas estruturas, um receptor de valor é muito barato, portanto, a menos que a semântica do método exija um ponteiro, um receptor de valor é eficiente e claro."

Primeiro ponto ele diz que é "muito barato", mas a questão é mais se é mais barato do que o receptor de ponteiro. Então fiz um pequeno benchmark (code on gist) que me mostrou que o receptor de ponteiro é mais rápido mesmo para uma estrutura que tem apenas um campo de string. Estes são os resultados:

// Struct one empty string property
BenchmarkChangePointerReceiver  2000000000               0.36 ns/op
BenchmarkChangeItValueReceiver  500000000                3.62 ns/op


// Struct one zero int property
BenchmarkChangePointerReceiver  2000000000               0.36 ns/op
BenchmarkChangeItValueReceiver  2000000000               0.36 ns/op

(Editar: observe que o segundo ponto se tornou inválido nas versões mais recentes de go, consulte os comentários) .
Segundo ponto , é "eficiente e claro", o que é mais uma questão de gosto, não é? Pessoalmente, prefiro consistência usando todos os lugares da mesma maneira. Eficiência em que sentido? Em termos de desempenho, parece que os ponteiros são quase sempre mais eficientes. Poucos testes com uma propriedade int mostraram vantagem mínima do receptor Value (intervalo de 0,01-0,1 ns / op)

Alguém pode me dizer um caso em que um receptor de valor claramente faz mais sentido do que um receptor de ponteiro? Ou estou fazendo algo errado no benchmark, esqueci outros fatores?

Chrisport
fonte
3
Executei benchmarks semelhantes com um único campo de string e também com dois campos: string e campos int. Obtive resultados mais rápidos do receptor de valor. BenchmarkChangePointerReceiver-4 10000000000 0,99 ns / op BenchmarkChangeItValueReceiver-4 10000000000 0,33 ns / op Isso está usando Go 1.8. Eu me pergunto se houve otimizações de compilador feitas desde a última vez que você executou os benchmarks. Veja a essência para mais detalhes.
pbitty
2
Você está certo. Executando meu benchmark original usando Go1.9, obtenho resultados diferentes agora também. Pointer Receiver 0.60 ns / op, Value receiver 0.38 ns / op
Chrisport

Respostas:

118

Observe que o FAQ menciona consistência

Em seguida, vem a consistência. Se alguns dos métodos do tipo devem ter receptores de ponteiro, o resto também deve, portanto, o conjunto de métodos é consistente, independentemente de como o tipo é usado. Consulte a seção sobre conjuntos de métodos para obter detalhes.

Conforme mencionado neste tópico :

A regra sobre ponteiros vs. valores para receptores é que os métodos de valor podem ser invocados em ponteiros e valores, mas métodos de ponteiro só podem ser invocados em ponteiros

Agora:

Alguém pode me dizer um caso em que um receptor de valor claramente faz mais sentido do que um receptor de ponteiro?

O comentário da revisão do código pode ajudar:

  • Se o receptor for um mapa, função ou canal, não use um ponteiro para ele.
  • Se o receptor for uma fatia e o método não cortar ou realocar a fatia, não use um ponteiro para ela.
  • Se o método precisa transformar o receptor, o receptor deve ser um ponteiro.
  • Se o receptor for uma estrutura que contém um sync.Mutexcampo de sincronização ou semelhante, o receptor deve ser um ponteiro para evitar a cópia.
  • Se o receptor for um grande struct ou array, um receptor de ponteiro é mais eficiente. Qual é o tamanho? Suponha que seja equivalente a passar todos os seus elementos como argumentos para o método. Se parecer muito grande, também é muito grande para o receptor.
  • A função ou os métodos, simultaneamente ou quando chamados a partir desse método, podem estar alterando o receptor? Um tipo de valor cria uma cópia do receptor quando o método é chamado, portanto, atualizações externas não serão aplicadas a este receptor. Se as alterações devem ser visíveis no receptor original, o receptor deve ser um ponteiro.
  • Se o receptor for um struct, array ou slice e qualquer de seus elementos for um ponteiro para algo que pode estar em mutação, prefira um receptor de ponteiro, pois isso tornará a intenção mais clara para o leitor.
  • Se o receptor é uma pequena matriz ou estrutura que é naturalmente um tipo de valor (por exemplo, algo como o time.Timetipo), sem campos mutáveis ​​e sem ponteiros, ou é apenas um tipo básico simples como int ou string, um receptor de valor faz sentido .
    Um receptor de valor pode reduzir a quantidade de lixo que pode ser gerada; se um valor é passado para um método de valor, uma cópia na pilha pode ser usada em vez de alocar no heap. (O compilador tenta ser inteligente ao evitar essa alocação, mas nem sempre pode ser bem-sucedido.) Não escolha um tipo de receptor de valor por esse motivo sem antes fazer o perfil.
  • Finalmente, em caso de dúvida, use um receptor de ponteiro.

A parte em negrito é encontrada, por exemplo, em net/http/server.go#Write():

// Write writes the headers described in h to w.
//
// This method has a value receiver, despite the somewhat large size
// of h, because it prevents an allocation. The escape analysis isn't
// smart enough to realize this function doesn't mutate h.
func (h extraHeader) Write(w *bufio.Writer) {
...
}
VonC
fonte
16
The rule about pointers vs. values for receivers is that value methods can be invoked on pointers and values, but pointer methods can only be invoked on pointers Não é verdade, na verdade. Ambos os métodos de receptor de valor e receptor de ponteiro podem ser chamados em um ponteiro ou não-ponteiro digitado corretamente. Independentemente de como o método é chamado, dentro do corpo do método, o identificador do receptor se refere a um valor por cópia quando um receptor de valor é usado e um ponteiro quando um receptor de ponteiro é usado: Veja play.golang.org/p / 3WHGaAbURM
Hart Simha
3
Há uma grande explicação aqui "Se x é endereçável e o conjunto de métodos de & x contém m, xm () é a abreviação de (& x) .m ()."
Tera
@tera Sim: isso é discutido em stackoverflow.com/q/43953187/6309
VonC
4
Ótima resposta, mas discordo veementemente deste ponto: "pois isso tornará a intenção mais clara", NOPE, uma API limpa, X como argumento e Y como valor de retorno é uma intenção clara. Passar um Struct por ponteiro e gastar tempo lendo cuidadosamente o código para verificar quais atributos foram modificados está longe de ser claro e sustentável.
Lukas Lukac
@HartSimha Acho que o post acima está apontando para o fato de que os métodos de receptor de ponteiro não estão em "conjunto de métodos" para tipos de valor. Em seu playground ligado, acrescentando seguinte linha irá resultar em erro de compilação: Int(5).increment_by_one_ptr(). Da mesma forma, uma característica que define o método increment_by_one_ptrnão ficará satisfeita com um valor do tipo Int.
Gaurav Agarwal
16

Para adicionar adicionalmente a @VonC, uma resposta excelente e informativa.

Estou surpreso que ninguém realmente mencionou o custo de manutenção, uma vez que o projeto fica maior, os desenvolvedores antigos saem e um novo chega. Go certamente é uma língua jovem.

De modo geral, tento evitar dicas quando posso, mas elas têm seu lugar e sua beleza.

Eu uso ponteiros quando:

  • trabalhar com grandes conjuntos de dados
  • tem um estado de manutenção de estrutura, por exemplo, TokenCache,
    • Certifico-me de que TODOS os campos são PRIVADOS, a interação só é possível através de receptores de métodos definidos
    • Eu não passo esta função para qualquer goroutine

Por exemplo:

type TokenCache struct {
    cache map[string]map[string]bool
}

func (c *TokenCache) Add(contract string, token string, authorized bool) {
    tokens := c.cache[contract]
    if tokens == nil {
        tokens = make(map[string]bool)
    }

    tokens[token] = authorized
    c.cache[contract] = tokens
}

Razões pelas quais evito dicas:

  • ponteiros não são simultaneamente seguros (todo o ponto de GoLang)
  • uma vez receptor de ponteiro, sempre receptor de ponteiro (para consistência em todos os métodos de Struct)
  • mutexes são certamente mais caros, mais lentos e mais difíceis de manter em comparação com o "custo de cópia de valor"
  • por falar em "custo de cópia de valor", isso é realmente um problema? A otimização prematura é a raiz de todos os males, você sempre pode adicionar dicas depois
  • diretamente, conscientemente me obriga a projetar pequenas estruturas
  • ponteiros podem ser evitados principalmente projetando funções puras com intenção clara e E / S óbvia
  • a coleta de lixo é mais difícil com ponteiros, acredito
  • mais fácil de discutir sobre encapsulamento, responsabilidades
  • mantenha-o simples, estúpido (sim, as dicas podem ser complicadas porque você nunca sabe o desenvolvimento do próximo projeto)
  • o teste de unidade é como caminhar pelo jardim rosa (expressão apenas eslovaca?), significa fácil
  • sem NIL se as condições (NIL pode ser passado onde um ponteiro era esperado)

Minha regra é escrever tantos métodos encapsulados quanto possível, como:

package rsa

// EncryptPKCS1v15 encrypts the given message with RSA and the padding scheme from PKCS#1 v1.5.
func EncryptPKCS1v15(rand io.Reader, pub *PublicKey, msg []byte) ([]byte, error) {
    return []byte("secret text"), nil
}

cipherText, err := rsa.EncryptPKCS1v15(rand, pub, keyBlock) 

ATUALIZAR:

Esta pergunta me inspirou a pesquisar mais sobre o tópico e escrever uma postagem no blog sobre ele https://medium.com/gophersland/gopher-vs-object-oriented-golang-4fa62b88c701

Lukas Lukac
fonte
Eu gosto de 99% do que você diz aqui e concordo totalmente com isso. Dito isso, estou me perguntando se seu exemplo é a melhor maneira de ilustrar seu ponto. O TokenCache não é essencialmente um mapa (de @VonC - "se o receptor for um mapa, função ou chan, não use um ponteiro para ele"). Uma vez que os mapas são tipos de referência, o que você ganha fazendo "Add ()" um receptor de ponteiro? Todas as cópias do TokenCache farão referência ao mesmo mapa. Veja este playground Go - play.golang.com/p/Xda1rsGwvhq
Rich
Ainda bem que estamos alinhados. Ótimo ponto. Na verdade, acho que usei o ponteiro neste exemplo porque o copiei de um projeto onde o TokenCache está lidando com mais coisas do que apenas aquele mapa. E se eu usar um ponteiro em um método, eu o uso em todos eles. Você sugere remover o ponteiro deste exemplo específico de SO?
Lukas Lukac
LOL, copiar / colar golpes de novo! 😉 IMO você pode deixá-lo como está, pois ilustra uma armadilha na qual é fácil cair, ou você pode substituir o mapa por algo (s) que demonstre o estado e / ou uma grande estrutura de dados.
Rico
Bem, tenho certeza que lerão os comentários ... PS: Rich, seus argumentos parecem razoáveis, me adicione no LinkedIn (link no meu perfil) prazer em conectar.
Lukas Lukac