Hey everyone! Welcome to the next article of our GoLang 101 series. In this article, we're going to tackle a concept that can seem a bit tricky at first: object-orientation in Go. If you've worked with languages like Python or Java, you're probably used to the idea of classes
. Go takes a slightly different approach, but the result is just as powerful and, in my opinion, even more elegant. Let's dive in.
What is a Class, Anyway?
Before we get into Go's unique style, let's quickly review the traditional definition of a class. A class is essentially a blueprint for an object. It’s a collection of data fields and functions (often called methods) that are all related to a single concept. The class itself is a template; it contains the data fields but not the actual data.
An object, on the other hand, is an instance of a class. It's a concrete item that holds actual data, following the template defined by the class. For example, a Point
class might define that a point has an x
and y
coordinate. An object of that class would be a specific point, like (5, 5)
, with actual values for x
and y
.
Go's Minimalist Approach to Object-Orientation
Go doesn't have a class
keyword. But don't let that fool you! It achieves a similar effect by associating methods with data. The key is the "receiver type."
Remember how we talked about structs in a previous article? In Go, you can define a struct to combine data fields into a single type, like so:
package main
import "fmt"
type Point struct {
x float64
y float64
}
func main() {
p := Point{x: 3, y: 4}
fmt.Println(p)
}
This struct is the "data fields" part of our class. To add the "methods" part, we use a receiver type when defining a function. A function with a receiver type is called a method and is associated with that specific type.
Here's how we'd add a method to our Point struct to calculate its distance from the origin using the Pythagorean theorem:
package main
import (
"fmt"
"math"
)
type Point struct {
x float64
y float64
}
// This is a method with a receiver type of Point
func (p Point) DistToOrigin() float64 {
// It's doing the Pythagorean theorem: sqrt(x^2 + y^2)
return math.Sqrt(p.x*p.x + p.y*p.y)
}
func main() {
p1 := Point{x: 3, y: 4}
distance := p1.DistToOrigin()
fmt.Println("Distance to origin:", distance)
}
Notice the (p Point) part before the function name DistToOrigin
. This is the receiver type. It tells Go that this method belongs to the Point type, and we can call it using dot notation, like p1.DistToOrigin()
.
Encapsulation and Controlled Access
Encapsulation is another core tenet of object-oriented programming. The idea is to hide internal data from the user of a class, allowing it to be accessed or modified only through defined methods. This prevents the user from accidentally making the data inconsistent.
Go achieves this through a simple yet powerful rule: capitalization
If a variable, type, or function name starts with a lowercase letter, it is private to the package it's defined in. It cannot be accessed by another package.
If it starts with a capital letter, it is public and can be accessed from any other package that imports it.
By giving data fields like x
and y
lowercase names, we can hide them from outside packages. We can then provide public, capitalized methods like Scale
or PrintMe
that control how that data is accessed and modified, ensuring consistency and safety.
Encapsulation is the concept of hiding data from a programmer and controlling how that data is accessed. This is typically done by making data accessible only through methods that are part of the class. In Go, this is achieved through a simple capitalization rule.
Let's expand on the previous example by creating a data package with a private variable and a public function to access it.
package data
// Point has lowercase fields to make them private
type Point struct {
x float64
y float64
}
// InitMe is a public method to initialize the Point
// It has a capital first letter
func (p *Point) InitMe(newX, newY float64) {
p.x = newX
p.y = newY
}
// PrintMe is a public method to display the coordinates
func (p *Point) PrintMe() {
fmt.Printf("Point coordinates: (%f, %f)\n", p.x, p.y)
}
in main
package, we can create a Point
object and use the public methods InitMe and PrintMe to interact with the private x
and y
fields.
package main
import (
"fmt"
"./data" // Assuming 'data' package is in the same directory
)
func main() {
p := data.Point{}
p.InitMe(3, 4) // We can call the public method to set x and y
p.PrintMe() // We can call the public method to print x and y
// This would result in a compile-time error because 'x' and 'y' are private
// p.x = 10
}
This way, we provide controlled access to the internal data. The programmer using our data package must use the public methods we've provided, which ensures the data remains consistent.
To Point, or Not to Point? Receiver Types and Mutability
So far, our methods have been passed a copy of the receiver object (call by value). This is fine if the method just reads data, like DistToOrigin
, but it's a problem if the method needs to change the data inside the object. If we try to modify a field within a method that has a value receiver, we're only modifying the copy, not the original object.
There's also a performance consideration: if the object is very large, making a copy every time you call a method can be slow and inefficient.
The solution? Use a pointer receiver.
By using a pointer to the struct as the receiver type, the method receives a pointer to the original object, not a copy. This allows the method to directly modify the data in the original object.
Here's how we'd write an OffsetX
method that actually modifies the point:
package main
import "fmt"
type Point struct {
x float64
y float64
}
// This method uses a pointer receiver (*Point) to modify the original struct
func (p *Point) OffsetX(v float64) {
p.x = p.x + v
}
func main() {
p1 := Point{x: 3, y: 4}
p1.OffsetX(5)
fmt.Println(p1) // Output: {8 4} - The x value has changed!
}
Notice the *Point
in the receiver. This is a pointer receiver. When you call p1.OffsetX(5)
, Go implicitly passes a pointer to p1
to the method, allowing the change to persist. It’s a convenient shortcut, as you don't even need to manually reference the object with &
when you make the call. You also don't need to dereference the pointer inside the function with (*p).x
, you can just use p.x
and Go handles it for you.
As a good programming practice, it's generally recommended to stick with either all pointer receivers or all value receivers for a given type to avoid confusion.
And that's it! By understanding structs, receiver types, and pointer receivers, you now have a solid grasp of how Go handles object-orientation. Happy coding!
Top comments (0)