Using Go Generics for Function Caching #
I’m currently engaged in a side project where I’m exploring web application
development using Golang. As part of this endeavor, I’m incorporating elements
from other web frameworks like Ruby on Rails to handle server-side HTML
rendering, database connection management, form handling, and more.
During the course of my work, I decided to dive into caching and wanted to
experiment with different strategies. One of the advantages of Golang over other
languages is its compilation and support for multi-threading through go
routines. I wanted to implement executable caching without relying on external
tools like Redis or Memcached.
There are various patterns for caching, often revolving around the concept of a
key-value store. To summarize it in haiku form:
Here is my data, It is named this way, give it back, I don’t care how.
In Golang, there are several in-process key-value stores available. One popular
choice is BoltDB due to its reliability and
battle-tested nature. However, upon careful consideration, it still felt too
similar to Redis. I was inclined to approach this challenge more
programmatically, perhaps even writing the caching mechanism myself.
Caching the return value of a function call requires knowledge of the types in
advance, so that the value can be appropriately cached.
This is a short example of the ergonomics I was aiming for.
package main
func SomeFunc() int {
// complex operation
return 1
}
func CacheWrapper(fun func() int) func() int {
var cached *int
return func() int {
if cached == nil {
value := fun()
cached = &value
}
return *cached
}
}
func main() {
invoke := CacheWrapper(SomeFunc)
invoke() // initial call
invoke() // using cached value
}
As you can see CachedWrapper
is ensuring that the expensive function is only
called once. This Just Works™ with int
return types, however. With early
Golang libraries, I’d have to create a CachedWrapper
for each return type I’d
ever want to use.
With more recent versions of Golang, however, generics were introduces to allow
types to be abstracted away. The type could be inferred at compiled time based
on the return type.
his is short example of the ergonomics I was aiming for. You’ll see the return
type referenced as R
in CacheWrapper
the function declaration.
package main
func SomeFunc() int {
// complex operation
return 1
}
func CacheWrapper[R any](fun func() R) func() R {
var cached *R // R represent the return function value
return func() R {
if cached == nil {
value := fun()
cached = &value
}
return *cached
}
}
func main() {
invoke := CacheWrapper(SomeFunc)
invoke() // initial call
invoke() // using cached value
}
NOTE: This is caching of a function call, not memoization. If we were
memoizing, we’d be taking arguments into account. The caching would be more
complex. Here we are just considering a function is called, and the returned
value is essential.
With R
as a placeholder, we can use any
type as the function’s return value.
It is inferred from the fun
return type, which allows us never to have to know
about it ahead of time explicitly. The compiler does the work for us.
I can use this in many places with minimal intrusion into my current codebase. I
have to import the CacheWrapper
function and use it. Generics in Go are
fantastic and save me so much code!
Some next steps for ensuring caching functionality would be expiration and
thread safeness. I’ll follow up with a continuation or a library.