Modules are a fundamental part of Go. They are collections of related packages versioned together. Every Go project includes a go.mod file, which defines the module path and keeps track of dependencies.
I am going to build a simple Go module for sorting slices and talk about my module design process. This module will be available here
Module Structure
Since this module is small, with only five exported functions, splitting it into multiple packages would add unnecessary complexity. That is why this module contains a single package with the same name (the root package). More complex modules usually contain multiple packages for different domains, but they also have a root package to act as an orchestrator for the other packages.
.
├── internal/
│ └── utils/
├── .gitignore
├── bubble.go
├── go.mod
├── insertion.go
├── LICENSE
├── merge.go
├── quick.go
├── README.md
├── selection.go
├── sort.go
└── sort_test.go
The internal folder is a special directory for Go. Packages in the internal folder cannot be imported outside of the module. This folder typically contains packages that provide logic or utilities used only by other packages in the same module.
Testing
As I mentioned, this module contains only one package. This is why I wrote a single test file that covers all functionality. General best practice for testing involves writing separate tests for each package.
I also like adding benchmarks to my test code. In fact, I prioritize benchmarks over tests. That’s because performance bottlenecks are often harder to identify at runtime than logical errors. While Go provides a toolkit for runtime performance analysis, identifying and resolving these issues before deployment makes my life easier.
Test Results
Since running benchmarks is actually the go test command with flags, I only run it once. When we execute go test -bench=. -benchmem, Go runs the tests first, if any test fails, the process aborts before benchmarking begins. If all tests pass, Go proceeds to run the benchmarks and outputs the results. I always include the -benchmem flag because, as I’ve mentioned in my previous posts, memory management is a critical part of working with Go.
Benchmark/BubbleSort-100-8 93960 11029 ns/op 0 B/op 0 allocs/op
Benchmark/InsertionSort-100-8 4611225 259.9 ns/op 0 B/op 0 allocs/op
Benchmark/MergeSort-100-8 918615 1283 ns/op 896 B/op 1 allocs/op
Benchmark/QuickSort-100-8 104880 11548 ns/op 0 B/op 0 allocs/op
Benchmark/SelectionSort-100-8 108484 11046 ns/op 0 B/op 0 allocs/op
Benchmark/BubbleSort-1000-8 1030 1160223 ns/op 0 B/op 0 allocs/op
Benchmark/InsertionSort-1000-8 478957 2506 ns/op 0 B/op 0 allocs/op
Benchmark/MergeSort-1000-8 78075 15324 ns/op 8192 B/op 1 allocs/op
Benchmark/QuickSort-1000-8 1135 1055280 ns/op 0 B/op 0 allocs/op
Benchmark/SelectionSort-1000-8 1150 1045421 ns/op 0 B/op 0 allocs/op
Benchmark/BubbleSort-10000-8 8 128257807 ns/op 0 B/op 0 allocs/op
Benchmark/InsertionSort-10000-8 47509 25394 ns/op 0 B/op 0 allocs/op
Benchmark/MergeSort-10000-8 5760 207292 ns/op 81920 B/op 1 allocs/op
Benchmark/QuickSort-10000-8 10 104685579 ns/op 0 B/op 0 allocs/op
Benchmark/SelectionSort-10000-8 10 103621325 ns/op 0 B/op 0 allocs/op
The benchmarking tool executed the functions multiple times to calculate these metrics.
The first column is the benchmark name. The second column indicates the number of iterations the tool ran to achieve a stable result, this number is determined dynamically by the benchmarking tool. The third column represents the average time per operation.
The fourth and fifth columns, enabled by the -benchmem flag, provide heap allocation analytics. As you can see, only the merge sort allocates the heap. To track partitions, I used a dynamic slice as a buffer. Because this buffer is dynamic and gets passed around functions, the Go compiler’s escape analysis moved it to the heap.
For heap allocation, the quantity of allocations is slightly more critical than the total size. This is because the Garbage Collector scans pointers, managing one large garbage is easier on the GC than managing many small ones. Since the merge sort only allocates once, and the alternative is changing implementation to a recursive approach, which introduces the risk of a stack overflow, the trade-off is more than fair.