Go es simple, pero eso no significa que sea fácil de aprender. Si bien su simplicidad proviene de su sintaxis familiar, la pequeña cantidad de palabras clave y el diseño opinado, algunas de estas decisiones de diseño tienen consecuencias significativas y hacen que el lenguaje sea difícil de dominar.
Una de esas decisiones es el paradigma utilizado para diseñar el lenguaje. ¡Go no es un lenguaje orientado a objetos (OO)! Se parece mucho a uno, pero no lo es. Las interfaces son un ejemplo perfecto de un concepto de OO utilizado en Go. Aunque son muy similares a las interfaces en otros lenguajes de OO, para dominarlas es esencial entender algunas diferencias clave que son inherentes a Go. En este artículo analizaremos más de cerca algunas de esas diferencias para que podamos escribir código Go mejor. Definiremos una regla para trabajar con interfaces.
Lo primero que debemos hacer, y probablemente lo más difícil, es olvidarnos de objetos, clases y herencia. ¡Esos conceptos no existen en Go!
Las interfaces son simples
La documentación oficial de Go ofrece algunos detalles sobre cómo usar interfaces (por ejemplo, A Tour Of Go, Effective Go). Aunque son útiles, no profundizan realmente. Nos dicen la sintaxis básica, pero no ofrecen orientación significativa sobre diseño, compensaciones o problemas.
Hay mucha información adicional disponible en la web, que proporciona ideas útiles sobre las mejores prácticas, pero a menudo carecen de detalles sobre por qué esta práctica es la mejor (por ejemplo, Using interfaces the right way, esta pregunta en Reddit). Curiosamente, la propia documentación de Google tiene la mayor parte de la información relevante disponible. Sin embargo, esto suele estar enterrado en guías de estilo (por ejemplo, Code Review Comments, Go Style Decision).
Entonces, en otras palabras, la información está en todas partes, pero es difícil encontrar información relevante. Esto puede ser particularmente frustrante porque hay mucho conocimiento implícito en Go.
Antes de entrar en acción, definamos primero un productor como un fragmento de código que proporciona una API y un consumidor como un fragmento de código que utiliza dicha API. El productor y el consumidor podrían estar en el mismo paquete, en paquetes o módulos diferentes, o incluso en bibliotecas diferentes. Estos dos conceptos son fundamentales para entender cómo el paradigma de Go difiere de los lenguajes de OO.
Con esas definiciones en mente, ahora podemos establecer esta regla: las interfaces deben definirse en el lado del consumidor. Veremos un ejemplo de pensamiento de OO aplicado a Go, destacando los problemas con él. Luego propondremos un enfoque más idiomático y veremos los beneficios.
¡Pero las interfaces no son fáciles!
Digamos que somos desarrolladores de Java experimentados. Ahora nos asignan un proyecto en Go y estamos ampliando nuestro conocimiento del lenguaje.
Necesitamos implementar una funcionalidad para almacenar mensajes. Provenientes de nuestro fondo de OO, analizamos nuestros requisitos y creamos una interfaz con los métodos requeridos por los diversos consumidores de nuestra API. Luego definimos un tipo concreto que representa un almacén en memoria. Este ejemplo se ilustra en el siguiente fragmento. Implementar los métodos queda a la creatividad del lector.
// Producer type Message struct{} type MessageStore interface { Get(index int) (Message, error) Add(m Message) error AddAt(index int, m Message) error Remove(m Message) error RemoveAt(index int) error Clear() error Contains(m Message) bool Size() int Empty() bool First() (Message, error) Last() (Message, error) Sort() error SortWith(f func(l, r Message) bool) error Filter(f func(m Message) bool) (MessageStore, error) FilterInPlace(f func(m Message) bool) error } type InMemoryMessageStore struct { store []Message }
A partir de ahí, podemos implementar consumidores que usarán esta interfaz. El siguiente fragmento ilustra un consumidor cuya función es imprimir mensajes.
// Consumer func PrintMessages(m MessageStore) { for i := 0; i < m.Size(); i++ { fmt.Println(m.Get(i)) } }
¿Ves el problema?
El consumidor solo necesita acceder a los mensajes. No necesita agregar, quitar o clasificar. Pero como consume la interfaz MessageStore, lo sabe todo al respecto. Esta función está fuertemente acoplada a la implementación y los detalles de diseño propios del productor.
MessageStore exporta muchos métodos. Algunos ni siquiera se utilizan. Esto se conoce en Go como contaminación de interfaces. Exportamos una interfaz grande y abultada que es difícil de cambiar. Y creamos una interfaz para ser implementada por un solo tipo.
Probar el consumidor es difícil. Necesitamos crear un tipo falso de prueba que implemente la interfaz. Y si queremos un simulacro real, el código de tipo falso se volverá extremadamente verboso. Hay herramientas que nos ayudarán a generar ese código, pero seguirá siendo una gran cantidad de plantilla. Podríamos usar el tipo concreto, pero eso viene con sus propias limitaciones. ¿Qué pasa si el almacén persiste mensajes en el disco? ¿O almacena mensajes en un servicio remoto?
Intentemos entender qué salió mal. En un lenguaje de OO, el tipo concreto tiene que implementar explícitamente la interfaz, por lo que la interfaz tiene que definirse en el lado del productor. Las cosas son un poco diferentes en Go: aquí, una interfaz se implementa implícitamente. Un tipo solo necesita implementar los métodos de esa interfaz. Podemos ver eso como una especie de mecanismo de mecanografía estructurada. El tipo concreto no tiene que conocer la interfaz. Como corolario, esto significa que la interfaz puede definirse en el lado del consumidor por lo que el consumidor necesita.
Aquí es donde Go difiere de los lenguajes de OO. Go no tiene jerarquías de tipos porque Go no se centra en la identidad. En Java, por ejemplo, un ArrayList es una List que es una Collection, y también es Iterable y Serializable, entre otras cosas. En Go, una interfaz simplemente define un comportamiento. Un escritor escribe, un lector lee. Pueden ser implementados por la misma entidad, pero tienen comportamientos distintos. Los consumidores solo están interesados en los comportamientos.
Adoptando la filosofía de Go
Reformulemos nuestro ejemplo con eso en mente. Nuestro consumidor solo necesita acceder a los mensajes. En el lado del consumidor, podemos crear una interfaz simple que especifique este requisito, como se ilustra en el siguiente fragmento. Las firmas de método de esta interfaz coinciden con los métodos del tipo concreto.
// Consumer type MessageAccessor interface { Get(index int) (Message, error) Size() int } func PrintMessages(m MessageAccessor) { for i := 0; i < m.Size(); i++ { fmt.Println(m.Get(i)) } }
En el lado del productor, eliminamos la interfaz. El resto del código es el mismo, el tipo concreto puede definir tantos métodos como sea necesario. El siguiente fragmento muestra el código mínimo requerido en el lado del productor para trabajar con nuestro consumidor.
// Producer type Message struct{} type InMemoryMessageStore struct { store []Message } func (m InMemoryMessageStore) Get(index int) (Message, error) { if index < m.Size() { return m.store[index], nil } return Message{}, errors.New("index out of bound") } func (m InMemoryMessageStore) Size() int { return len(m.store) }
¿Por qué esta es una solución mejorada?
- Las dependencias de la API se mantienen al mínimo. El consumidor espera un comportamiento especificado por la interfaz y no sabe nada más.
- El productor no exporta interfaces innecesarias, por lo que no hay contaminación de interfaces.
- Probar el consumidor es más fácil. Simular o stub requiere solo dos métodos; se puede hacer en línea manteniendo la legibilidad.
- El acoplamiento entre el productor y el consumidor se minimiza. Si el productor cambia, es trivial adaptar la interfaz. En las situaciones más complejas, podemos usar el patrón adaptador para gestionar las diferencias.
Sin embargo, mirando esos puntos cuidadosamente, todos se pueden aplicar a cualquier lenguaje de OO. Son manifestaciones de los principios SOLID. La diferencia clave es lo que Go nos permite hacer. Como las interfaces se implementan implícitamente, pueden, y deben, implementarse en el lado del consumidor. De esta manera, se pueden descomponer a su forma más simple (es común en Go tener interfaces con un solo método, por ejemplo, Stringer, io.Reader o io.Writer de la biblioteca estándar). En el lado del productor, no es necesario reflexionar demasiado sobre nuestro diseño e intentar prever cada caso de uso potencial.
¡Lo más importante que tuvimos que hacer fue quitarnos nuestra gorra de pensamiento de OO!
Un ejemplo del mundo real
Este ejemplo ficticio ilustra la forma recomendada de trabajar con interfaces. Pero ¿qué pasa con el mundo real?
El módulo de websocket al que hace referencia la documentación de Go produce un tipo Conn que representa la conexión websocket, y varios métodos y funciones para operar el websocket. No define ninguna interfaz. Un consumidor de este módulo tendrá que definir sus propios requisitos a través de una o varias interfaces.
Cuando no aplicar la regla
¿Y cuál sería una regla sin ejemplos contrarios?
El paquete io de la biblioteca estándar define y exporta varias interfaces pequeñas como Reader y Writer. Lo hace porque también exporta varias funciones que utilizan esas interfaces (por ejemplo, Copy(Writer, Reader)). Invierte el flujo al exportar interfaces para que el consumidor las implemente, por lo que el consumidor puede usar las funciones exportadas. En este contexto, el consumidor del paquete io está interesado en las funciones; las interfaces son solo un medio para un fin.
Notas de cierre
En este artículo hemos visto las deficiencias de escribir código Go con una mentalidad de OO. ¡Vale la pena repetir que Go no es un lenguaje de OO! Al venir de OO, necesitamos olvidar lo que sabemos y aprender a abrazar la filosofía de Go. Una interfaz debería escribirse en el lado del consumidor. Esto permite un diseño más simple y menos dependencias, lo que resulta en un código más limpio. Escribir una interfaz en el lado del productor sigue siendo una opción, pero debemos sopesar los pros y los contras antes de elegir este enfoque.
Aviso legal: Las declaraciones y opiniones expresadas en este artículo son las del autor/a o autores y no reflejan necesariamente las posiciones de Thoughtworks.