Go Functions vs. Methods:
Have you ever wondered about the difference between functions and methods in Go? I certainly did when I first encountered them. At a glance, they might seem similar, both execute code and produce results. But there’s a fundamental difference in how they relate to the data they operate on. In this article, I’ll take you on my journey from using basic functions to discovering the elegance of methods in Go. We’ll build a simple rectangle program, examine its limitations, and gradually improve it. By the end, you’ll understand why methods often lead to cleaner, more intuitive code.
On a free medium plan? Read here for free.
I have a basic program to calculate the area of a rectangle using functions, taking the width and height:
package main
import "fmt"
func main() {
width := 5.0
height := 3.0
area := calculateArea(width, height)
fmt.Printf("The area of the rectangle is: %.2f\n", area)
}
func calculateArea(w, h float64) float64 {
return w * h
}This works fine. I defined a function that took two parameters and returned a value. Pretty straightforward.
The problem with this approach was that I treated the width and height as separate, unrelated values. If I wanted to calculate multiple properties of the same rectangle or pass it around to different functions, I needed to keep track of these separate values and ensure they stayed together. This became cumbersome as the program grew.
For example, the below code calculates both the area and perimeter for multiple rectangles.
package main
import "fmt"
func main() {
// Rectangle 1
width1 := 5.0
height1 := 3.0
// Rectangle 2
width2 := 10.0
height2 := 7.0
// Calculate properties for Rectangle 1
area1 := calculateArea(width1, height1)
perimeter1 := calculatePerimeter(width1, height1)
// Calculate properties for Rectangle 2
area2 := calculateArea(width2, height2)
perimeter2 := calculatePerimeter(width2, height2)
fmt.Printf("Rectangle 1: Area = %.2f, Perimeter = %.2f\n", area1, perimeter1)
fmt.Printf("Rectangle 2: Area = %.2f, Perimeter = %.2f\n", area2, perimeter2)
}
func calculateArea(w, h float64) float64 {
return w * h
}
func calculatePerimeter(w, h float64) float64 {
return 2 * (w + h)
}Looking at this code, I see a few issues that make it less than ideal:
- First, it’s quite repetitive. For each rectangle, I have to separately define width and height variables, then make separate function calls to calculate their properties. If I had 10 rectangles, that would be 20 separate variables and 20 function calls; that’s a lot of copy-pasting!
- Second, it’s error-prone. Notice how I had to carefully name my variables
width1,height1,width2,height2, etc. What if I accidentally useheight1withwidth2in a calculation? The compiler won't catch this mistake, but my results would be wrong. - Third, it’s not scalable. What if I want to add more properties to my rectangles, like position coordinates, color, or rotation? I’d need to create even more separate variables and keep track of them all.
- Fourth, the relationships between the data aren’t clear. When reading this code, it’s not immediately obvious that
width1andheight1belong together as parts of the same rectangle. - Finally, if I wanted to pass a rectangle to another function, I’d need to pass both width and height separately. The more properties I add, the more parameters I’d need:
Let’s see if there is a better way to do this. Go provides structs for storing related values together. A struct is essentially a collection of fields, each with a name and a type. Think of it like a form where you fill in different fields: name, age, address, etc. Each field has a specific purpose and type of data it can hold.
Now that we know about structs, let’s see how they will help us. We can define a Rectangle struct:
type Rectangle struct {
width float64
height float64
}Now with values defined in the struct, I can call them when needed and use functions with the struct:
package main
import "fmt"
type Rectangle struct {
width float64
height float64
}
func calculateArea(rect Rectangle) float64 {
return rect.width * rect.height
}
func calculatePerimeter(rect Rectangle) float64 {
return 2 * (rect.width + rect.height)
}
func main() {
// Create rectangle instances
rect1 := Rectangle{width: 5.0, height: 3.0}
rect2 := Rectangle{width: 10.0, height: 7.0}
// Calculate properties for Rectangle 1
area1 := calculateArea(rect1)
perimeter1 := calculatePerimeter(rect1)
// Calculate properties for Rectangle 2
area2 := calculateArea(rect2)
perimeter2 := calculatePerimeter(rect2)
fmt.Printf("Rectangle 1: Area = %.2f, Perimeter = %.2f\n", area1, perimeter1)
fmt.Printf("Rectangle 2: Area = %.2f, Perimeter = %.2f\n", area2, perimeter2)
}This is already a big improvement. I now have a proper type to represent rectangles, and my functions take a rectangle as a parameter instead of separate width and height values.
Before, our code was treating width and height as two completely separate things. It’s like keeping track of a person’s name and age separately everywhere in your code. You’d have to remember to always bring both values together whenever you needed to work with a rectangle.
// The old way: passing separate values
area := calculateArea(width, height)
perimeter := calculatePerimeter(width, height)With a struct, we’re now packaging width and height together into a single unit called Rectangle. It’s like having a form or a profile card for a rectangle that contains all its important information in one place.
This is much better because:
- We only need to create the rectangle once and then pass it around
- We’re less likely to mix up dimensions between different rectangles
- It makes our function signatures cleaner. Instead of
calculateArea(w, h float64), we havecalculateArea(rect Rectangle) - If we later decide to add more properties to our rectangle (like color), we don’t need to update all the function calls
Think of a struct like a container that keeps related data together, making it easier to manage and less prone to errors.
Get Rajesh Kumar’s stories in your inbox
Join Medium for free to get updates from this writer.
Let’s say I want to add a function to calculate the diagonal of a rectangle:
// The new way: passing a complete rectangle object
rect := Rectangle{width: 5.0, height: 3.0}
area := calculateArea(rect)
perimeter := calculatePerimeter(rect)import "math"
func calculateDiagonal(rect Rectangle) float64 {
return math.Sqrt(rect.width*rect.width + rect.height*rect.height)
}I can call it just like the other functions:
func main() {
// Create rectangle instances
rect1 := Rectangle{width: 5.0, height: 3.0}
rect2 := Rectangle{width: 10.0, height: 7.0}
// Calculate properties for Rectangle 1
area1 := calculateArea(rect1)
perimeter1 := calculatePerimeter(rect1)
diagonal1 := calculateDiagonal(rect1)
// Calculate properties for Rectangle 2
area2 := calculateArea(rect2)
perimeter2 := calculatePerimeter(rect2)
diagonal2 := calculateDiagonal(rect2)
fmt.Printf("Rectangle 1: Area = %.2f, Perimeter = %.2f, Diagonal = %.2f\n",
area1, perimeter1, diagonal1)
fmt.Printf("Rectangle 2: Area = %.2f, Perimeter = %.2f, Diagonal = %.2f\n",
area2, perimeter2, diagonal2)
}This works well for calculations that only depend on the width and height. But what if I want to expand my Rectangle type to include more properties? Let’s say I want to add position coordinates and color:
type Rectangle struct {
width float64
height float64
x float64 // X position
y float64 // Y position
color string // Color
}Now I can create more detailed rectangles from command-line arguments:
func calculateArea(rect Rectangle) float64 {
return rect.width * rect.height
}
func calculatePerimeter(rect Rectangle) float64 {
return 2 * (rect.width + rect.height)
}
func calculateDiagonal(rect Rectangle) float64 {
return math.Sqrt(rect.width*rect.width + rect.height*rect.height)
}
func main() {
// Create more detailed rectangles
rect1 := Rectangle{
width: 5.0,
height: 3.0,
x: 0.0,
y: 0.0,
color: "blue",
}
rect2 := Rectangle{
width: 10.0,
height: 7.0,
x: 3.0,
y: 2.0,
color: "red",
}
// Calculate basic properties
area1 := calculateArea(rect1)
perimeter1 := calculatePerimeter(rect1)
diagonal1 := calculateDiagonal(rect1)
// Print results
fmt.Println(describeRectangle(rect1))
fmt.Printf("Area = %.2f, Perimeter = %.2f, Diagonal = %.2f\n",
area1, perimeter1, diagonal1)
}My existing functions like calculateArea, calculatePerimeter, and calculateDiagonal still work fine because they only use the width and height properties. We can add new functions that use the position and color:
func isPointInside(rect Rectangle, pointX, pointY float64) bool {
return pointX >= rect.x &&
pointX <= rect.x + rect.width &&
pointY >= rect.y &&
pointY <= rect.y + rect.height
}
func doRectanglesOverlap(rect1, rect2 Rectangle) bool {
return rect1.x < rect2.x + rect2.width &&
rect1.x + rect1.width > rect2.x &&
rect1.y < rect2.y + rect2.height &&
rect1.y + rect1.height > rect2.y
}
func describeRectangle(rect Rectangle) string {
return fmt.Sprintf("A %s rectangle at position (%.1f,%.1f) with dimensions %.1fx%.1f",
rect.color, rect.x, rect.y, rect.width, rect.height)
}And I can use these new functions:
// Check if a point is inside rect1
pointX, pointY := 2.0, 1.0
isInside := isPointInside(rect1, pointX, pointY)
if isInside {
fmt.Printf("Point (%.1f,%.1f) is inside the blue rectangle\n", pointX, pointY)
} else {
fmt.Printf("Point (%.1f,%.1f) is not inside the blue rectangle\n", pointX, pointY)
}
// Check overlap
if overlap {
fmt.Println("The blue and red rectangles overlap")
} else {
fmt.Println("The blue and red rectangles don't overlap")
}
// Describe the rectangle
fmt.Println(describeRectangle(rect2))This approach works, but there’s a limitation: as I add more functionality related to rectangles, I end up with many separate functions scattered throughout my code. The functions and the data they operate on are separated, which doesn’t reflect the natural relationship between a rectangle and its properties or behaviors.
For example, when I write isPointInside(rect, pointX, pointY), it doesn't clearly express that I'm asking if a point is inside the rectangle. It would be more natural to ask the rectangle itself if it contains a point.
Methods can solve this problem. A method is a function that’s attached to a particular type. It can access the fields of the type directly and expresses a clear relationship between the type and the behavior.
Let’s rewrite our program using methods:
package main
import (
"fmt"
"math"
"os"
"strconv"
)
type Rectangle struct {
width float64
height float64
x float64
y float64
color string
}
// Methods attached to Rectangle type
func (r Rectangle) Area() float64 {
return r.width * r.height
}
func (r Rectangle) Perimeter() float64 {
return 2 * (r.width + r.height)
}
func (r Rectangle) Diagonal() float64 {
return math.Sqrt(r.width*r.width + r.height*r.height)
}
func (r Rectangle) ContainsPoint(x, y float64) bool {
return x >= r.x &&
x <= r.x + r.width &&
y >= r.y &&
y <= r.y + r.height
}
func (r Rectangle) OverlapsWith(other Rectangle) bool {
return r.x < other.x + other.width &&
r.x + r.width > other.x &&
r.y < other.y + other.height &&
r.y + r.height > other.y
}
func (r Rectangle) Description() string {
return fmt.Sprintf("A %s rectangle at position (%.1f,%.1f) with dimensions %.1fx%.1f",
r.color, r.x, r.y, r.width, r.height)
}
func main() {
// Format: program width height x y color
if len(os.Args) != 6 {
fmt.Println("Usage: go run program.go <width> <height> <x> <y> <color>")
fmt.Println("Example: go run program.go 5.0 3.0 0.0 0.0 blue")
os.Exit(1)
}
// Parse width
width, err := strconv.ParseFloat(os.Args[1], 64)
if err != nil {
fmt.Println("Error: Width must be a valid number")
os.Exit(1)
}
// Parse height
height, err := strconv.ParseFloat(os.Args[2], 64)
if err != nil {
fmt.Println("Error: Height must be a valid number")
os.Exit(1)
}
// Parse x
x, err := strconv.ParseFloat(os.Args[3], 64)
if err != nil {
fmt.Println("Error: X position must be a valid number")
os.Exit(1)
}
// Parse y
y, err := strconv.ParseFloat(os.Args[4], 64)
if err != nil {
fmt.Println("Error: Y position must be a valid number")
os.Exit(1)
}
// Get color
color := os.Args[5]
// Create rectangle
rect := Rectangle{
width: width,
height: height,
x: x,
y: y,
color: color,
}
// Calculate properties
fmt.Printf("Area = %.2f, Perimeter = %.2f, Diagonal = %.2f\n",
rect.Area(), rect.Perimeter(), rect.Diagonal())
// Check if a point is inside the rectangle
pointX, pointY := 2.0, 1.0
if rect.ContainsPoint(pointX, pointY) {
fmt.Printf("Point (%.1f,%.1f) is inside the rectangle\n", pointX, pointY)
} else {
fmt.Printf("Point (%.1f,%.1f) is not inside the rectangle\n", pointX, pointY)
}
// Describe the rectangle
fmt.Println(rect.Description())
}Notice the difference in how we call these functions:
With functions:
area := calculateArea(rect) // "Calculate the area of this rectangle"
isInside := isPointInside(rect, x, y) // "Is this point inside this rectangle?"With methods:
area := rect.Area() // "Rectangle, what is your area?"
isInside := rect.ContainsPoint(x, y) // "Rectangle, does this point lie within you?"The methods make the code more readable and intuitive because they reflect the natural relationship between objects and their behaviors.
Now let’s say I want to modify a rectangle, like scaling its size. With functions, I would need to return a new Rectangle type:
func scaleRectangle(r Rectangle, factor float64) Rectangle {
r.width *= factor
r.height *= factor
return r
}
// Using it:
rect = scaleRectangle(rect, 2.0) // Need to reassignBut with methods, I can use a pointer receiver to modify the original rectangle:
func (r *Rectangle) Scale(factor float64) {
r.width *= factor
r.height *= factor
}
// Using it:
rect.Scale(2.0) // Modifies the original rect directlThe pointer receiver version is more efficient (no need to copy the entire struct) and more intuitive (it modifies the rectangle in place).
Here’s a complete example showing how we can use methods with a pointer receiver, accepting a scale factor from the command line:
package main
import (
"fmt"
"math"
"os"
"strconv"
)
type Rectangle struct {
width float64
height float64
x float64
y float64
color string
}
// These methods don't modify the rectangle, so we use value receivers
func (r Rectangle) Area() float64 {
return r.width * r.height
}
func (r Rectangle) Perimeter() float64 {
return 2 * (r.width + r.height)
}
func (r Rectangle) Description() string {
return fmt.Sprintf("A %s rectangle at position (%.1f,%.1f) with dimensions %.1fx%.1f",
r.color, r.x, r.y, r.width, r.height)
}
// These methods modify the rectangle, so we use pointer receivers
func (r *Rectangle) Scale(factor float64) {
r.width *= factor
r.height *= factor
}
func (r *Rectangle) Move(dx, dy float64) {
r.x += dx
r.y += dy
}
func (r *Rectangle) ChangeColor(newColor string) {
r.color = newColor
}
func main() {
// Format: program width height x y color scaleFactor
if len(os.Args) != 7 {
fmt.Println("Usage: go run program.go <width> <height> <x> <y> <color> <scaleFactor>")
fmt.Println("Example: go run program.go 5.0 3.0 0.0 0.0 blue 2.0")
os.Exit(1)
}
// Parse arguments
width, err := strconv.ParseFloat(os.Args[1], 64)
if err != nil {
fmt.Println("Error: Width must be a valid number")
os.Exit(1)
}
height, err := strconv.ParseFloat(os.Args[2], 64)
if err != nil {
fmt.Println("Error: Height must be a valid number")
os.Exit(1)
}
x, err := strconv.ParseFloat(os.Args[3], 64)
if err != nil {
fmt.Println("Error: X position must be a valid number")
os.Exit(1)
}
y, err := strconv.ParseFloat(os.Args[4], 64)
if err != nil {
fmt.Println("Error: Y position must be a valid number")
os.Exit(1)
}
color := os.Args[5]
scaleFactor, err := strconv.ParseFloat(os.Args[6], 64)
if err != nil {
fmt.Println("Error: Scale factor must be a valid number")
os.Exit(1)
}
// Create rectangle
rect := Rectangle{
width: width,
height: height,
x: x,
y: y,
color: color,
}
fmt.Printf("Original rectangle: %s\n", rect.Description())
fmt.Printf("Area: %.2f, Perimeter: %.2f\n", rect.Area(), rect.Perimeter())
// Modify the rectangle
rect.Scale(scaleFactor)
rect.Move(1.0, 1.0)
rect.ChangeColor("green")
fmt.Printf("Modified rectangle: %s\n", rect.Description())
fmt.Printf("New area: %.2f, New perimeter: %.2f\n", rect.Area(), rect.Perimeter())
}To summarize the benefits of methods over functions:
- Organization: Methods attach behavior directly to the data they work with, creating a more cohesive structure.
- Readability:
rect.Area()reads more naturally thancalculateArea(rect) - Modification: With pointer receivers, methods can modify the original value without creating copies.
- Encapsulation: Methods help encapsulate behavior with the data it operates on, making the code more maintainable.
While functions can certainly do everything methods can do, methods provide a more intuitive and organized way to work with custom types in Go. They help us think about our code in terms of objects with associated behaviors, which often maps more naturally to the problems we’re trying to solve.
Connect & Continue Learning 🚀
Enjoyed this article? Clap until your fingers hurt (or just 50 times, whichever comes first)!
Read my other articles — because one tech rabbit hole deserves another!
Connect with me on LinkedIn for more tech insights; it’s like subscribing, but with fewer annoying notifications.
Have a suggestion for my next post? Drop a comment below! I read them all (even the ones suggesting I should’ve used spaces instead of tabs).
P.S. Let me know what topics you’d like me to cover next!









