Consider a scenario, You have two glasses one with milk and the other with buttermilk. Your task is to identify between milk and buttermilk before drinking it(of course without tasting and touching!).
Hard to admit that, right? Just because they seem similar doesn’t mean they are the same.
The same happens with loosely typed languages. When we provide arguments without mentioning types (as they don’t expect), the identification relies on runtime whether it will be a number or string(milk and buttermilk in the above example).
For example, You have a function that accepts a variable as input, but unless we specify its type, we can’t admit that it will always be a number or a string. Because it can be either one or completely a different one — boolean!
There come the strictly/strongly typed languages in the picture. No doubt loosely typed languages come with more freedom but with the cost of robustness!
In languages like JavaScript and PHP, variables dance to the beat of their assigned values, morphing from numbers to strings with nary a complaint.
But for those who’ve migrated to Golang, the world of strict typing can feel…well, a bit rigid at first.
Two separate functions for int
and string
?
Even though having the same computation logic?
Where's the flexibility?
But why to fear, when generics are here?
Generics are important for writing reusable and expressive code. In strongly typed languages, generics are a way to go. However, Golang had no support for generics until Go1.17, it started supporting from Go1.18 and later.
In this blog, we will explore what are the disadvantages of loosely typed languages and how generics bridges a gap between the expressiveness of loose typing and strongly typed languages(Golang).
We are what we repeatedly do. Excellence, then, is not an act, but a habit. Try out Justly and start building your habits today!
Imagine an e-commerce website built with PHP, a loosely typed language. A function calculates a discount based on a user’s loyalty points stored in a variable $points
.
function calculateDiscount($points) {
if ($points > 100) {
$discount = $points * 0.1; // Assuming points are integers
} else {
$discount = 0;
}
return $discount;
}
$userPoints = "Gold"; // User with "Gold" loyalty tier
$discount = calculateDiscount($userPoints); // Unexpected behavior
// This might result in a runtime error or unexpected discount calculation
// due to the string value in $userPoints.
Here, the function expects an integer for $points
to calculate the discount. However, due to loose typing, a string value ("Gold") is passed.
This might lead to a runtime error(as it’s not pre-compiled) or an unexpected discount calculation, causing confusion for the user and requiring additional debugging efforts.
Now, let’s consider the same scenario in Go, a strictly typed language.
func calculateDiscount(points int) float64 {
if points > 100 {
return float64(points) * 0.1
}
return 0
}
var userPoints int = 150 // User's loyalty points stored as an integer
discount := calculateDiscount(userPoints)
// This code will not compile due to the incompatible type of "userPoints"
// if it's not declared as an integer initially.
In Go, the function calculateDiscount
explicitly requires an integer for points
.
If we attempt to pass the string value "Gold", the code won't even compile. This early error detection prevents unexpected behavior at runtime and ensures data integrity.
However, while strictly typed languages don’t provide as much flexibility as loosely typed it ensures the robustness of our code. While not being so rigid, strictly typed languages provide support for generics to ensure code reusability and cleanliness.
Generics are a powerful programming concept that allows you to write functions and data structures that can work with a variety of different data types without sacrificing type safety.
Let’s consider a simple example,
A function that takes an array as input be it []int64
or []float64
and returns the sum of all its elements.
Ideally, we would need to define two different functions for each.
// SumInts adds together the values of m.
func SumInts(m []int64) int64 {
var s int64
for _, v := range m {
s += v
}
return s
}
// SumFloats adds together the values of m.
func SumFloats(m []float64) float64 {
var s float64
for _, v := range m {
s += v
}
return s
}
With generics, it’s possible to use a single function that behaves the same for different data types.
// SumIntsOrFloats sums the values of map m. It supports both int64 and float64
// as types for array values.
func SumIntsOrFloats[T int64 | float64](m []T) T{
var s T
for _, t:= range m {
s += t
}
return s
}
This function takes []T
as input and give T
as output, it can be any of the two we mentioned int64 or float64.
The magic lies in the T
placeholder. It represents a generic type that can be anything int64
or float64
. This eliminates the need for duplicate functions and keeps our code clean and concise.
Go generics introduce type parameters, allowing functions and data structures to work with various types. However, sometimes, we need to ensure specific properties for those types. This is where type constraints come in.
comparable
Constraint: Not for Ordering The comparable
constraint is a natural choice for ordering elements.
After all, it guarantees types can be compared for equality using the ==
operator. However, comparable
doesn't imply the ability to use comparison operators like <
, >
, <=
, and >=
.
Example:
func IntArrayContains(arr []int, target int) bool {
for _, element := range arr {
if element == target {
return true
}
}
return false
}
func StringArrayContains(arr []string, target string) bool {
for _, element := range arr {
if element == target {
return true
}
}
return false
}
The above function takes int
and string
respectively and checks whether an element exists in an array or not.
func ArrayContains[T comparable](arr []T, target T) bool {
for _, element := range arr {
if element == target {
return true
}
}
return false
}
The above generic function is a replacement if the IntArrayContains()
and StringArrayContains()
. It can also be used for float(Try providing ([]float64{2.3,4.5,1.2}, 4.5)
as argument!
Where,
[T comparable] —defines a generic type parameter T
with comparable
constraint. This ensures the elements in the slice can be compared using the ==
operator.
ordered
Constraint: For OrderingFor ordering elements within a generic function, Go offers the Ordered constraint from the cmp package.
The ordered
constraint ensures the type parameter can be used with comparison operators like <
, >
, <=
, and >=
, making functions like Min
or Max
possible.
Example:
A function to find the minimum value in a slice. Traditionally, we might have written separate functions for different types like int
and string
.
func minInt(s []int) int {
min := s[0]
for _, val := range s {
if val < min {
min = val
}
}
return min
}
func minString(s []string) string {
min := s[0]
for _, val := range s {
if val < min { // String comparison might not be intuitive
min = val
}
}
return min
}
With generics, we can define a single generic function Min
that works with any comparable type.
import "cmp"
func Min[T cmp.Ordered](s []T) T {
min := s[0]
for _, val := range s {
if val < min {
min = val
}
}
return min
}
[T cmp.Ordered] — It defines a generic type parameter T
. The cmp.Ordered
part specifies a constraint on T
. It must be a type that implements the Ordered
interface (meaning it supports comparison operators like <, >
etc).
Have you faced a situation when you need to implement two separate functions with the same computational logic, just to process two different structs? Sounds relevant right?
While using generics, we can bind a method and a property with a struct that supports any datatype.
Example:
package main
import "fmt"
type Stack[T any] struct { // define struct with type T(int64/string/float64 etc)
elements []T
}
func (s *Stack[T]) Push(val T) {
s.elements = append(s.elements, val)
}
func main() {
// Stack of integers
intStack := Stack[int]{}
intStack.Push(10)
intStack.Push(20)
fmt.Println("intStack : ", intStack)
// Stack of strings
stringStack := Stack[string]{}
stringStack.Push("Hello")
stringStack.Push("World")
fmt.Println("stringStack : ", stringStack)
}
Here,
Stack[T any]
is a generic struct and acts as a placeholder for any type.[Stack T any]
has elements
property that will hold the elements of type T
.Push(val T)
method is defined on the Stack
struct. It takes a value of type T
and appends it to the elements
slice.T
allows the Stack
struct and its Push
method to work with various data types.For those of us who’ve embraced the freedom (and occasional chaos) of loose typing, generics offer a compelling middle ground.
They provide the flexibility we’re used to but with the added benefit of type safety and code clarity.
So, the next time you find yourself yearning for the days of “anything goes,” remember the power of generics in Golang. They’ll help you write cleaner, more maintainable code, all while keeping the type safe and happy.