Receptor de valor frente a receptor de puntero

107

No está muy claro para mí en cuyo caso querría usar un receptor de valor en lugar de usar siempre un receptor de puntero.
Para recapitular de los 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

Los documentos también dicen: "Para tipos como tipos básicos, porciones y estructuras pequeñas, un receptor de valores es muy barato, por lo que, a menos que la semántica del método requiera un puntero, un receptor de valores es eficiente y claro".

El primer punto dice que es "muy barato", pero la pregunta es más si es más barato que el receptor de puntero. Así que hice un pequeño punto de referencia (código en esencia) que me mostró que el receptor de puntero es más rápido incluso para una estructura que tiene solo un campo de cadena. Estos son los 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: tenga en cuenta que el segundo punto dejó de ser válido en las versiones más recientes de Go, consulte los comentarios) .
Segundo punto dice, es "eficiente y claro" lo que es más una cuestión de gustos, ¿no? Personalmente, prefiero la coherencia al usarlo en todas partes de la misma manera. ¿Eficiencia en qué sentido? En cuanto al rendimiento, parece que los punteros son casi siempre más eficientes. Pocas pruebas con una propiedad int mostraron una ventaja mínima del receptor Value (rango de 0.01-0.1 ns / op)

¿Alguien puede decirme un caso en el que un receptor de valores claramente tenga más sentido que un receptor de puntero? ¿O estoy haciendo algo mal en el punto de referencia? ¿Pasé por alto otros factores?

Chrisport
fuente
3
Ejecuté puntos de referencia similares con un campo de cadena única y también con dos campos: campos de cadena e int. Obtuve resultados más rápidos del receptor de valor. BenchmarkChangePointerReceiver-4 10000000000 0,99 ns / op BenchmarkChangeItValueReceiver-4 10000000000 0,33 ns / op Esto está utilizando Go 1.8. Me pregunto si se realizaron optimizaciones del compilador desde la última vez que ejecutó los puntos de referencia. Consulte la esencia para obtener más detalles.
pbitty
2
Tienes razón. Al ejecutar mi punto de referencia original con Go1.9, ahora también obtengo resultados diferentes. Receptor de puntero 0.60 ns / op, Receptor de valor 0.38 ns / op
Chrisport

Respuestas:

117

Tenga en cuenta que las preguntas frecuentes mencionan la coherencia

Lo siguiente es la coherencia. Si algunos de los métodos del tipo deben tener receptores de puntero, el resto también debería hacerlo, por lo que el conjunto de métodos es coherente independientemente de cómo se utilice el tipo. Consulte la sección sobre conjuntos de métodos para obtener más detalles.

Como se menciona en este hilo :

La regla sobre punteros frente a valores para receptores es que los métodos de valor se pueden invocar en punteros y valores, pero los métodos de puntero solo se pueden invocar en punteros

Ahora:

¿Alguien puede decirme un caso en el que un receptor de valores claramente tenga más sentido que un receptor de puntero?

El comentario de Revisión de código puede ayudar:

  • Si el receptor es un mapa, func o chan, no use un puntero hacia él.
  • Si el receptor es un segmento y el método no vuelve a dividir o reasignar el segmento, no utilice un puntero.
  • Si el método necesita mutar el receptor, el receptor debe ser un puntero.
  • Si el receptor es una estructura que contiene un sync.Mutexcampo de sincronización o similar, el receptor debe ser un puntero para evitar la copia.
  • Si el receptor es una estructura o matriz grande, un receptor de puntero es más eficiente. ¿Qué tan grande es? Suponga que es equivalente a pasar todos sus elementos como argumentos al método. Si se siente demasiado grande, también es demasiado grande para el receptor.
  • ¿Pueden la función o los métodos, al mismo tiempo o cuando se llaman desde este método, mutar el receptor? Un tipo de valor crea una copia del receptor cuando se invoca el método, por lo que las actualizaciones externas no se aplicarán a este receptor. Si los cambios deben ser visibles en el receptor original, el receptor debe ser un puntero.
  • Si el receptor es una estructura, matriz o segmento y cualquiera de sus elementos es un puntero a algo que podría estar mutando, prefiera un receptor de puntero, ya que hará que la intención sea más clara para el lector.
  • Si el receptor es una pequeña matriz o estructura que es naturalmente un tipo de valor (por ejemplo, algo como el time.Timetipo), sin campos mutables ni punteros, o es simplemente un tipo básico simple como int o string, un receptor de valor hace sentido .
    Un receptor de valor puede reducir la cantidad de basura que se puede generar; si se pasa un valor a un método de valor, se puede usar una copia en la pila en lugar de asignar en el montón. (El compilador intenta ser inteligente al evitar esta asignación, pero no siempre puede tener éxito). No elija un tipo de receptor de valor por esta razón sin crear un perfil primero.
  • Finalmente, en caso de duda, utilice un receptor de puntero.

La parte en negrita se encuentra, por ejemplo, en 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
fuente
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 En realidad, no es cierto. Los métodos de receptor de valor y de receptor de puntero pueden invocarse en un puntero o no puntero correctamente escrito. Independientemente de cómo se llame al método, dentro del cuerpo del método, el identificador del receptor se refiere a un valor por copia cuando se usa un receptor de valor, y un puntero cuando se usa un receptor de puntero: Ver play.golang.org/p / 3WHGaAbURM
Hart Simha
3
Hay una gran explicación aquí: "Si x es direccionable y el conjunto de métodos de & x contiene m, xm () es la abreviatura de (& x) .m ()".
tera
@tera Sí: eso se discute en stackoverflow.com/q/43953187/6309
VonC
4
Gran respuesta, pero estoy totalmente en desacuerdo con este punto: "ya que aclarará la intención", NOPE, una API limpia, X como argumento e Y como valor de retorno es una intención clara. Pasar una estructura por puntero y dedicar tiempo a leer detenidamente el código para comprobar qué atributos se modifican está lejos de ser claro y fácil de mantener.
Lukas Lukac
@HartSimha Creo que la publicación anterior apunta al hecho de que los métodos del receptor de puntero no están en el "conjunto de métodos" para los tipos de valor. En su campo de juego, el añadido siguiente línea dará lugar a error de compilación: Int(5).increment_by_one_ptr(). Del mismo modo, un rasgo que define el método increment_by_one_ptrno se satisfará con un valor de tipo Int.
Gaurav Agarwal
16

Para agregar adicionalmente a @VonC, una gran respuesta informativa.

Me sorprende que nadie haya mencionado realmente el costo de mantenimiento una vez que el proyecto crece, los desarrolladores antiguos se van y llega uno nuevo. Go seguramente es un idioma joven.

En general, trato de evitar los consejos cuando puedo, pero tienen su lugar y su belleza.

Uso punteros cuando:

  • trabajar con grandes conjuntos de datos
  • tener un estado de mantenimiento de estructura, por ejemplo, TokenCache,
    • Me aseguro de que TODOS los campos sean PRIVADOS, la interacción solo es posible a través de receptores de métodos definidos
    • No paso esta función a ninguna goroutine

P.ej:

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
}

Razones por las que evito los consejos:

  • los punteros no son simultáneamente seguros (el objetivo de GoLang)
  • una vez receptor de puntero, siempre receptor de puntero (para todos los métodos de Struct para la coherencia)
  • los mutex son seguramente más caros, más lentos y más difíciles de mantener en comparación con el "costo de copia de valor"
  • hablando de "costo de copia de valor", ¿es eso realmente un problema? La optimización prematura es la raíz de todos los males, siempre puede agregar punteros más tarde
  • me obliga de forma directa y consciente a diseñar pequeñas estructuras
  • Los punteros se pueden evitar principalmente diseñando funciones puras con una intención clara y una E / S obvia.
  • la recolección de basura es más difícil con los punteros, creo
  • más fácil de discutir sobre encapsulación, responsabilidades
  • manténgalo simple, estúpido (sí, los punteros pueden ser complicados porque nunca se sabe el desarrollador del próximo proyecto)
  • La prueba unitaria es como caminar por un jardín rosa (¿expresión única en eslovaco?), significa fácil
  • no NIL if condiciones (NIL se puede pasar donde se esperaba un puntero)

Mi regla general es escribir tantos métodos encapsulados como sea posible, 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) 

ACTUALIZAR:

Esta pregunta me inspiró a investigar más el tema y escribir una publicación de blog al respecto https://medium.com/gophersland/gopher-vs-object-oriented-golang-4fa62b88c701

Lukas Lukac
fuente
Me gusta el 99% de lo que dices aquí y estoy totalmente de acuerdo con eso. Dicho esto, me pregunto si su ejemplo es la mejor manera de ilustrar su punto. ¿No es TokenCache esencialmente un mapa (de @VonC - "si el receptor es un mapa, func o chan, no use un puntero hacia él"). Dado que los mapas son tipos de referencia, ¿qué se gana al convertir "Add ()" en un receptor de puntero? Cualquier copia de TokenCache hará referencia al mismo mapa. Vea este patio de juegos de Go - play.golang.com/p/Xda1rsGwvhq
Rich
Me alegro de que estemos alineados. Gran punto. En realidad, creo que he usado un puntero en este ejemplo porque lo copié de un proyecto en el que TokenCache está manejando más cosas que solo ese mapa. Y si utilizo un puntero en un método, lo uso en todos. ¿Sugiere eliminar el puntero de este ejemplo de SO en particular?
Lukas Lukac
LOL, ¡copia / pega strikes de nuevo! 😉 En mi opinión, puede dejarlo como está, ya que ilustra una trampa en la que es fácil caer, o puede reemplazar el mapa con algo que demuestre el estado y / o una gran estructura de datos.
Rich
Bueno, estoy seguro de que leerán los comentarios ... PD: Rich, tus argumentos parecen razonables, agrégame en LinkedIn (enlace en mi perfil) feliz de conectar.
Lukas Lukac