4️⃣

Clase 4: Programación orientada a objetos en Go


Objetivos
  1. Comprender los cuatro principios fundamentales de la programación orientada a objetos (abstracción, encapsulamiento, herencia y polimorfismo) y su aplicación en el lenguaje de programación Go.
  1. Escribir un programa en Go que utilice estructuras (structs) para definir un tipo de dato personalizado, implemente métodos para manipular ese tipo de dato y utilice interfaces para permitir la interoperabilidad con otros tipos de datos.
  1. Utilizar la composición para construir una estructura compleja a partir de estructuras más simples, aplica el encapsulamiento para restringir el acceso a ciertos campos y métodos, y utiliza el polimorfismo a través de interfaces para permitir que diferentes tipos implementen un conjunto común de métodos.

Contenido de la clase:

1. Conceptos básicos de programación orientada a objetos

La programación orientada a objetos (POO) es un paradigma de programación que se basa en la idea de estructurar el código en torno a objetos que representan entidades del mundo real.

  1. Encapsulación

    Es un principio de la programación orientada a objetos que consiste en ocultar los detalles internos de un objeto y proporcionar una interfaz controlada para interactuar con él. Es decir, se busca limitar el acceso directo a los datos y métodos internos de un objeto y en su lugar, se utilizan métodos públicos para interactuar con él.

    💡
    La encapsulación contiene toda la información importante de un objeto dentro del mismo y solo expone la información seleccionada al mundo exterior.
  1. Abstracción

    Este concepto está muy relacionado con el anterior

    Como la propia palabra indica, el principio de abstracción lo que implica es que la clase debe representar las características de la entidad hacia el mundo exterior, pero ocultando la complejidad que lleva. O sea, nos abstrae de la complejidad que haya dentro dándonos una serie de atributos y comportamientos que podemos usar sin preocuparnos de qué pasa por dentro cuando lo hagamos.

    💡
    La abstracción está muy relacionada con la encapsulación, pero va un paso más allá pues no sólo controla el acceso a la información, sino también oculta la complejidad de los procesos que estemos implementando.
  1. Herencia

    La herencia permite que se puedan definir nuevas clases basadas de unas ya existentes a fin de reutilizar el código, generando así una jerarquía de clases dentro de una aplicación. Si una clase deriva de otra, esta hereda sus atributos y métodos y puede añadir nuevos atributos, métodos o redefinir los heredados.

    💡
    Permite crear nuevas clases basadas en clases existentes.
  1. Polimorfismo

    Es la capacidad de un objeto para tomar diferentes formas o comportarse de diferentes maneras.

    💡
    Permite que objetos de diferentes clases respondan de manera distinta a un mismo mensaje ó método, lo que facilita la flexibilidad del código.

¿Por qué la programación orientada a objetos?

Permite que el código sea reutilizable, organizado y sencillo de mantener. Sigue el principio de desarrollo de software DRY (Don’t Repeat Yoursealf), para no duplicar código y crear programas eficientes. Asimismo previene el acceso no deseado a los datos o la exposición de código propietario a través de la encapsulación y la abstracción.

¿Es Go un lenguaje de programación orientado a objetos?

Go es un lenguaje de programación que admite la programación orientada a objetos de manera limitada en comparación a otros lenguajes de programación como Java.

https://go.dev/doc/faq#Is_Go_an_object-oriented_language

https://blog.friendsofgo.tech/posts/es_go_un_lenguaje_orientado_a_objetos/

¿Cómo es la programación orientada a objetos en Go?

A diferencia de lenguajes explícitamente orientados a objetos como Java, en Go no existen las clases, ni los objetos, ni la herencia de clases.

Existen tipos de datos definidos por el usuario a los cuales se les puede incorporar comportamientos.

En una analogía con una clase, las propiedades pudieran ser los tipos de datos de una estructura, y los métodos las funciones o comportamientos asociados al tipo de dato.

💡
Cuando se habla de objetos en Go, generalmente se utiliza de manera informal para referirse a las instancias de una estructura (struct). Aunque técnicamente Go no tiene una representación nativa de objetos como en otros lenguajes de programación orientado a objetos, el término objeto se utiliza a menudo como una forma conveniente de describir una instancia de una estructura en Go.
2. Definición de estructuras (structs), métodos e interfaces

En Go, las clases no existen como un concepto independiente como lo hacen en otros lenguajes de programación orientados a objetos. Sin embargo, es posible lograr funcionalidad similar a las clases utilizando structs y métodos asociados a ellos.

  • Structs

    Es un tipo de dato que permite agrupar varios valores de diferentes tipos en una sola estructura. Los valores se llaman campos y cada campo tiene un nombre y un tipo.

    Son útiles para agrupar datos relacionados en una sola estructura de datos y proporcionar una manera organizada y eficiente de trabajar con ellos.

    💡
    En resumen, los structs en Go son una herramienta muy importante para organizar y manejar datos de manera eficiente y legible en tu código, y son esenciales para crea aplicaciones complejas y escalables.
    package main
    import "fmt"
    
    // Struct global
    type Person struct {
    	name string
    	age  int
    }
    
    func main() {
    	// Instanciar un struct
    	carlos := Person{name: "Carlos"}
    	carlos.age = 26
    	fmt.Printf("%+v", carlos)
    }
  • Métodos

    En Go, los métodos son funciones asociadas a un tipo de dato específico. Se definen y se declaran dentro del alcance de un tipo, lo que permite que ese tipo tenga comportamientos específicos asociados a él. Los métodos son una forma de extender la funcionalidad de los tipos de datos y proporcionan una forma de realizar operaciones o acciones relacionadas con ese tipo.

    Los métodos en Go se definen utilizando una función con un receptor (receiver) asociado al tipo en el que se definen. El receptor es un parámetro especial que permite que el método sea invocado en una instancia particular del tipo. El receptor se coloca antes del nombre de la función y puede ser un valor o un puntero al tipo.

    Hay dos tipos de receptores en Go:

    1. Receptor de valor (value receiver): El método actúa sobre una copia del valor del receptor. Se utiliza cuando no se necesita modificar el valor del receptor.
    1. Receptor de puntero (pointer receiver): El método actúa directamente sobre el valor del receptor a través de un puntero. Se utiliza cuando se desea modificar el valor del receptor.

    1. Declaramos una struct Person

    package main
    import "fmt"
    
    // Struct global
    type Person struct {
    	name string
    	age  int
    }
    1. Delcaración de métodos
    // Receptor de puntero
    func (p *Person) setName(name string) {
    	p.name = name
    }
    
    // Receptor de valor
    func (p Person) getName() string {
    	return p.name
    }
    
    // Receptor de puntero
    func (p *Person) setAge(age int) {
    	p.age = age
    }
    
    // Receptor de valor
    func (p Person) getAge() int {
    	return p.age
    }
    
    func (p Person) toString() string {
    	return fmt.Sprintf("*** Nombre: %s, Edad: %d ***", p.getName(), p.getAge())
    }
    1. Instanciamos Person y utilizamos los métodos
    func main() {
    	// Creación de una instancia del tipo Person
    	carlos := Person{}
    	// Llamada a los métodos del tipo Person
    	carlos.setName("Carlos")
    	carlos.setAge(26)
    	fmt.Println("Mi nombre es:", carlos.getName())
    	fmt.Println("Mi edad es: ", carlos.getAge())
    	fmt.Println(carlos.toString())
    }
    1. Resultado final
    package main
    import "fmt"
    
    type Person struct {
    	name string
    	age  int
    }
    
    func (p *Person) setName(name string) {
    	p.name = name
    }
    
    func (p *Person) setAge(age int) {
    	p.age = age
    }
    
    func (p Person) getName() string {
    	return p.name
    }
    
    func (p Person) getAge() int {
    	return p.age
    }
    
    func (p Person) toString() string {
    	return fmt.Sprintf("*** Nombre: %s, Edad: %d ***", p.getName(), p.getAge())
    }
    
    func main() {
    	carlos := Person{}
    	carlos.setName("Carlos")
    	carlos.setAge(26)
    	fmt.Println("Mi nombre es:", carlos.getName())
    	fmt.Println("Mi edad es: ", carlos.getAge())
    	fmt.Println(carlos.toString())
    }

    En Go, es posible definir métodos no solo para structs, si no también para tipos de datos básicos y personalizados, como enteros, cadenas de texto y otros tipos definidos por el usuario utilizando la palabra clave type.

    Esto se logra mediante la definición de un tipo nuevo basado en un tipo existente y la declaración de métodos asociados a ese nuevo tipo. A este proceso se le conoce como “receptor con nombre” (named receiver) en Go.

    package main
    import (
    	"fmt"
    	"strings"
    )
    
    // Definición de un tipo nuevo basado en un tipo existente (string)
    type MyString string
    
    // Método asociado al tipo MyString: cuenta el número de caracteres en la cadena
    func (m MyString) length() int {
    	return len(m)
    }
    
    // Método asociado al tipo MyString: convierte la cadena en mayúsculas
    func (m MyString) toUpper() string {
    	return strings.ToUpper(string(m))
    }
    
    func main() {
    	text := MyString("Hola, mundo!")
    
    	// Llamada a los métodos asociados al tipo MyString
    	length := text.length()
    	fmt.Println("Número de caracteres:", length)
    
    	textUpper := text.toUpper()
    	fmt.Println("Cadena en mayúsculas:", textUpper)
    }
    package main
    import "fmt"
    
    // Definición de un tipo nuevo basado en int
    type MyInt int
    
    // Método asociado al tipo MyInt: verifica si el entero es par
    func (i MyInt) IsEven() bool {
    	return i%2 == 0
    }
    
    // Método asociado al tipo MyInt: duplica el valor del entero
    func (i MyInt) Double() MyInt {
    	return i * 2
    }
    
    func main() {
    	number := MyInt(7)
    
    	// Llamada a los métodos asociados al tipo MyInt
    	fmt.Println("¿Es par?", number.IsEven())
    
    	double := number.Double()
    	fmt.Println("Valor double:", double)
    }
  • Interfaces

    Las interfaces en Go son un tipo de dato que se usa para representar el comportamiento de otros tipos. Una interfaz es como un plano técnico o un contrato que un objeto debe cumplir. Cuando se usan interfaces, el código base se vuelve más flexible y adaptable, porque se escribe código que no está vinculado a una implementación concreta. Por tanto, se puede extender la funcionalidad de un programa rápidamente.

    A diferencia de las interfaces de otros lenguajes de programación, en Go son de forma implícita. Go no ofrece palabras clave para implementar una interfaz (e.g. implements).

    1. Declaración de una interfaz

    Una interfaz en Go es como un plano técnico. Un tipo abstracto que solo incluye los métodos que un tipo concreto debe poseer o implementar.

    package main
    
    type Shape interface {
    	Perimeter() float64
    	Area() float64
    }
    1. Implementar una interfaz

    Como se mencionó anteriormente, en Go no hay una palabra clave para implementar una interfaz. Un tipo cumple de forma implícita una interfaz en Go cuando tiene todos los métodos que necesita una interfaz.

    Ahora se creará una struct Square que tenga los dos métodos de la interfaz Shape.

    type Square struct {
    	size float64
    }
    
    func (s Square) Area() float64 {
    	return s.size * s.size
    }
    
    func (s Square) Perimeter() float64 {
    	return s.size * 4
    }
    
    func main() {
    	// Instanciamos Square
      var s Shape = Square{3}
      fmt.Printf("%T\n", s)
      fmt.Println("Area: ", s.Area())
      fmt.Println("Perimeter:", s.Perimeter())
    }
    type Circle struct {
        radius float64
    }
    
    func (c Circle) Area() float64 {
        return math.Pi * c.radius * c.radius
    }
    
    func (c Circle) Perimeter() float64 {
        return 2 * math.Pi * c.radius
    }
    
    func main() {
    	// Instanciamos Square
      var s Shape = Square{3}
      fmt.Printf("%T\n", s)
      fmt.Println("Area: ", s.Area())
      fmt.Println("Perimeter:", s.Perimeter())
    
    	// Instanciamos Circle
      c := Circle{6}
      fmt.Printf("%T\n", c)
      fmt.Println("Area: ", c.Area())
      fmt.Println("Perimeter:", c.Perimeter())
    }

    Ahora se refactorizará la función main y se creará una función para imprimir el tipo del objeto que recibe, junto con su área y perímetro.

    func printInformation(s Shape) {
    	// Tipo de dato
    	fmt.Printf("%T\n", s)
    	// Área
    	fmt.Println("Area: ", s.Area())
    	// Perímetro
    	fmt.Println("Perimeter:", s.Perimeter())
    	// Salto de línea
    	fmt.Println()
    }
    func main() {
        var s Shape = Square{3}
        printInformation(s)
    
        c := Circle{6}
        printInformation(c)
    }
  • Constructores [Extra]

    Go no tiene un mecanismo similar a los constructores de otros lenguajes orientados a objetos. Pero, es bastante común crear una función que los emule, devuelviendo un objeto ya inicializado con los parámetros que querramos aplicar.

    package main
    import "fmt"
    
    type Person struct {
    	name string
    	age int
    	active bool
    }
    1. Crear un struct con valores zero por defecto.
    func main() {
    	p := Person{}
    	fmt.Println(p)
    }
    1. Asignarle valores a las propiedades.
    func main() {
    	p := Person{name: "Carlos", age: 26, active: true}
    	fmt.Println(p)
    }
    1. Usando la palabra reservada new: Regresa una referencia de memoria a la instancia creada, para acceder al valor usamos el operador *
    func main() {
    	p := new(Person)
    	fmt.Println(p)
    }
    1. Crear una función que actúe como método constructor: La función regresa con & para que regresa la referencia de memoria y no una copia de la struct.
    func NewPerson(name string, age int, active bool) Person {
    	return Person{
    		name:   name,
    		age:    age,
    		active: active,
    	}
    }
    
    func main() {
    	p := NewPerson("Carlos", 26, true)
    	fmt.Println(p)
    }
3. Composición, encapsulamiento y polimorfismo en Go
  • Composición

    En Go no existe la herencia, en su lugar, Go utiliza la composición como un enfoque para la reutilización de código y la construcción de relaciones entre tipos de datos

    La composición es un concepto en la programación orientada a objetos que permite construir objetos complejos combinando o "componiendo" varios objetos más simples. En lugar de heredar directamente características de una clase base, la composición utiliza objetos como componentes para construir una clase más grande y más especializada.

    En Go, la composición se logra mediante la inclusión de un tipo de estructura dentro de otra estructura como un campo. Esto permite que los campos y métodos del tipo incluido estén disponibles en el tipo que lo contiene.

    package main
    import "fmt"
    
    // Definición de la estructura "Engine"
    type Engine struct {
    	Type     string
    	Potencia int
    }
    
    // Definición de la estructura "Car" que utiliza composición para incluir un "Engine"
    type Car struct {
    	Model  string
    	Color  string
    	Engine Engine // Campo "Engine" como parte de la composición
    }
    
    func main() {
    	// Creación de un objeto "Engine"
    	engine := Engine{Type: "Gasolina", Potencia: 150}
    
    	// Creación de un objeto "Car" que incluye el objeto "Engine"
    	car := Car{Model: "Sedán", Color: "Rojo", Engine: engine}
    
    	// Acceso a los campos del objeto "Car"
    	fmt.Println("Modelo:", car.Model)
    	fmt.Println("Color:", car.Color)
    
    	// Acceso a los campos y métodos del objeto "Engine" a través del objeto "Car"
    	fmt.Println("Tipo de motor:", car.Engine.Type)
    	fmt.Println("Potencia del motor:", car.Engine.Potencia)
    }
  • Encapsulamiento

    El encapsulamiento permite proteger los datos internos de un objeto y garantizar que solo puedan ser modificados a través de métodos específicos. Esto ayuda a mantener la integridad de los datos y facilita el mantenimiento y la evolución del código.

    En Go no existen identificadores de privacidad tales como public, protected y pirvate típicos de otros lenguajes de programación. Go encapsula tipos de datos y funciones a nivel de paquetes en base a convenciones de nombres.

    Todos aquellos nombres que empiecen con mayúsculas serán accesibles (visibles) desde otros paquetes. Por el contrario aquellos que comiencen con minúsculas serán privados.

    1. Estructura del proyecto
    1. Archivo main.go
    package main
    
    import (
    	. "curso-practico-go/poo/person"
    	"fmt"
    )
    
    func main() {
    	// Creación de un objeto "Persona"
    	persona := Persona{}
    
    	// Establecer el nombre y la edad a través de los métodos de encapsulamiento
    	persona.SetNombre("Carlos")
    	persona.SetEdad(26)
    
    	// Obtener el nombre y la edad a través de los métodos de encapsulamiento
    	fmt.Println("Nombre:", persona.GetNombre())
    	fmt.Println("Edad:", persona.GetEdad())
    }
    1. Archivo person.go
    package person
    
    // Definición de la estructura "Persona"
    type Persona struct {
    	nombre string
    	edad   int
    }
    
    // Método para establecer el nombre de la persona
    func (p *Persona) SetNombre(nombre string) {
    	p.nombre = nombre
    }
    
    // Método para obtener el nombre de la persona
    func (p Persona) GetNombre() string {
    	return p.nombre
    }
    
    // Método para establecer la edad de la persona
    func (p *Persona) SetEdad(edad int) {
    	p.edad = edad
    }
    
    // Método para obtener la edad de la persona
    func (p Persona) GetEdad() int {
    	return p.edad
    }
  • Polimorfismo

    En Go, el polimorfismo se logra mediante la implementación de interfaces. Una interfaz como ya vimos es una colección de métodos que se definen sin implementación. Una estructura o tipo de dato puede implementar una interfaz si proporciona una implementación para todos los métodos definidos en la interfaz.

    1. Declaración de interfaz
    package main
    import "fmt"
    
    // Declaramos una interfaz llamada Animal
    type Animal interface {
    	Sound() string
    }
    1. Declaramos los structs Dog y Cat
    // Declaramos el struct Dog
    type Dog struct {}
    
    // Declaramos el struct Cat
    type Cat struct {}
    1. Definimos los métodos para los structs
    // Método para el struct Dog
    func (d Dog) Sound() string {
    	return "Woof!"
    }
    
    // Método para el struct Cat
    func (c Cat) Sound() string {
    	return "Meow!"
    }
    1. Utilizamos la interfaz creando una función
    func PrintSound(a Animal) {
    	fmt.Printf("%T \n", a)
    	fmt.Println("El sonido es:", a.Sound())
    }
    1. Instanciamos los structs y utilizamos la función PrintSound()
    func main() {
    	// Instanciamos Cat
    	myCat := Cat{}
    	PrintSound(myCat)
    	// Instanciamos Dog
    	myDog := Dog{}
    	PrintSound(myDog)
    }
    1. Conclusión

    El polimorfismo nos permite tratar estos objetos de diferentes tipos de manera uniforme a través de la interfaz común. En el caso de PrintSound(), independientemente de si se le pasa un Dogo un Cat como argumento, puede llamar el método Sound() en el parámetro a Animal. Esto se debe a que Dog y Cat implementan la interfaz Animal y proporcionan una implementación del método Sound(). La función PrintSound() no necesita conocer el tipo subayacente real del objeto que recibe, solo necesita asegurarse de que se implemente la interfaz Animal.

    El uso de interfaces y el polimorfismo nos permite escribir código más genérico y flexible, ya que nos permite tratar objetos de diferentes tipos de manera uniforme. A través de las interfaces, podemos definir un contrato común para múltiples tipos y escribir funciones que operen en esos tipos sin conocer su tipo subayacente real, lo que facilita la extensibilidad y reutilización del código.

4. Tarea
  1. Crear una struct "Rectangle" con métodos para calcular el área y el perímetro.
// Solución
package main
import "fmt"

type Rectangle struct {
	Length float64
	Width  float64
}

func (r Rectangle) CalculateArea() float64 {
	return r.Length * r.Width
}

func (r Rectangle) CalculatePerimeter() float64 {
	return 2 * (r.Length + r.Width)
}

func main() {
	rect := Rectangle{Length: 5, Width: 3}

	area := rect.CalculateArea()
	fmt.Println("El área del rectángulo es:", area)

	perimeter := rect.CalculatePerimeter()
	fmt.Println("El perímetro del rectángulo es:", perimeter)
}
  1. Crear una struct "BankAccount" con métodos para depositar, retirar y consultar el saldo.
// Solución
package main
import "fmt"

type BankAccount struct {
	Owner   string
	Balance float64
}

func (b *BankAccount) Deposit(amount float64) {
	b.Balance += amount
}

func (b *BankAccount) Withdraw(amount float64) {
	if amount <= b.Balance {
		b.Balance -= amount
	} else {
		fmt.Println("Saldo insuficiente")
	}
}

func (b BankAccount) CheckBalance() float64 {
	return b.Balance
}

func main() {
	account := BankAccount{Owner: "Carlos Córdova", Balance: 1000}

	account.Deposit(500)
	fmt.Println("Saldo después del depósito:", account.CheckBalance())

	account.Withdraw(200)
	fmt.Println("Saldo después del retiro:", account.CheckBalance())
}
5. Recursos