Enforcing the use of constructors in Go
4 minutes read. Published:
Table of contents
Disclaimer: This is not idiomatic go code. The whole puprpose of this post was to explore possibile solutions, even if not idiomatic
When using golang, everyone with access to an exported type can create an instance of it, bypassing the use of a constructor function.
Sometimes though you want to protect you struct data from accidental modifications and keep an always valid internal state.
Let's see the problem in code
The problem
// file db.go
// Having a File struct means already having Read access
type File struct {
Path string
Write bool
Append bool
Delete bool
}
func GetFile(path string, userID int) (*File, error) {
// Check the user permissions...
userCanRead := false
if !userCanRead {
return nil, errors.New("Can't read file")
}
return &File{path, false, true, false}, nil
}
// file main.go
// Using a safe function
file, err := db.GetFile("/etc/passwd", 0)
fmt.Println("1", file, err)
// Unsafely creating the struct, violating the invariants
file := db.File{"/etc/passwd", false, true, false}
fmt.Println(file)
I shouldn't be able to create a struct File manually and neither should I be able to manipulate my permissions.
Solution 1: Unexported type
If a type is unexported, the user of the library can't create it manually
The type will be named file
instead of File
// file db.go
// Having a file struct means already having Read access
type file struct {
path string
canWrite bool
canAppend bool
canDelete bool
}
func GetFile(path string, userID int) (*file, error) {
// Return private type *file
}
Problems:
- External modules can't write functions accepting
db.file
as argument because it's unexported. I can't even declare a variable of typedb.file
, so I can't save it in any data structure.
Solution 2: Unexported type + interface
I may create an interface around the file
type
type FileIntf interface {
GetPath()
CanAppend()
CanWrite()
CanDelete()
}
This is not a valid solution though because now I can create a fake type
FakeFile
and implement this interface, manipulating my permissions.
Also, some functions may only accept the internal file
type, so I may have to
convert between the two, checking every time if the conversion was valid.
Solution 3: Unexported type + wrapper type
// file db.go
type FileWrapper struct {
file *file
}
func (fileWrapper FileWrapper) func Get() {
return *fileWrapper.file
}
I can still create a db.FileWrapper
with default values (having the
file
field set to nil
). That means using the type is impractical, because
every time I use the struct I should check if the internal state was contructed
correctly or not.
file := db.FileWrapper{}
// This will fail, because the internal `*file` pointer is nil
fmt.Println(file.Get())
Solution 4: Unexported type + closure
By saving the data inside an anonymous function I can then pass data around
safely, without doing any nil
check.
// file db.go
type FileFuncWrap func() file
func (f file) WrapFunc() FileFuncWrap {
return func() {return f}
}
// file main.go
file := db.GetFile("/etc/passwd", 0)
printFile := func(unwrapFile db.FileFuncWrap) {
fmt.Println(unwrapFile())
}
// Look! I can even pass it around!
printFile(file.WrapFunc())
Solution 5: Unexported type + identity interface
The previous solution probably works ok, but creating many anonymous functions everytime you pass data around may be a bit costly. We saw that functions returning an internal type may be useful... Let's try doing something better.
//file db.go
type FileIdentity interface {
Identity() file
}
// obviously `file` implements the interface
func (f file) Identity() file {
return f
}
The interface returns the internal file
type, so another package can't implement
the interface. (That's exactly what I want)
Now we should be able to store a file by saving it as a FileIdentity
//file main.go
file, _ := db.GetFile("/etc/passwd", 0)
arr := []db.FileIdentity{file} // Look how simple it's to store it! Without conversions!
printFile := func(fI db.FileIdentity) {
fmt.Println(fI.Identity())
}
printFile(arr[0]) // YAY!
A type returning himself should be way faster than creating a closure everytime.
Solution 6: Unexported type + interface with private method
Thankfully someone on reddit provided me with an even better solution.
By creating an interface with a private method, nobody can implement the interface outside the db
package,
solving the quirks of Solution 2
// file db.go
type file struct {
path string
canWrite bool
canAppend bool
canDelete bool
}
func (f file) Path() string {return f.path}
func (f file) CanWrite() bool {return f.canWrite}
func (f file) CanAppend() bool {return f.canAppend}
func (f file) CanDelete() bool {return f.canDelete}
func (f file) markFileAsValid() {/* This block is actually empty */}
type File interface {
Path() string
CanAppend() bool
CanWrite() bool
CanDelete() bool
markFileAsValid()
}
// file main.go
file := db.GetFile("/etc/passwd", 0)
printFile := func(f db.File) { // Much simpler than solution 5
fmt.Println(f)
}
arr := []db.File{file}
printFile(arr[0])
Working code
You can see and run the last example here