Chipaca’s Miscellanea

en español

Integer math

I very much enjoy making tabular data easy to read on the terminal. Often, this takes me to strange and wonderful places.

tabular data and scanning and pretty

When writing tabular data to the terminal, ‘pretty’ is often a shorthand for ‘easy to read’, ‘easy to scan’, and ‘aligned’. Nail the last one and you get most of the other two already.

So if you have a bunch of numbers (I’m talking ints and the like, here), you want them to line up in a way the user expects, and that is usually I have no idea how this works in languages where text doesn’t flow left-to-right, beyond that they exist, and that it’s complicated and contextual, so I’m assuming left-to-right text and look forward to learning about other things some day with numbers aligned to the right.

Year  Units   Net
2021       7    7
2022      14  -23
2023  654386    5

If you’re using Go, and using its standard library’s text/tabwriter, and all you’re printing is numbers, and you don’t mind if your column headers are also aligned to the right, it has a handy AlignRight formatting option that aligns everything to the right and then you’re done:

	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.AlignRight)

gives you

  Year   Units  Net
  2021       7    7
  2022      14  -23
  2023  654386    5

which is quite good.

how long is that number in the window?

However, sometimes you have other things than numbers mixed in, like a column of ASCII text Non-ASCII text will be covered in a future article. . Or you are required to align the column headers to the left. In this case, you need to align the numbers to the right using something like fmt.Sprintf("%5d", num), where often you’ll have a natural upper bound for the width of the numbers as presented on your terminal (e.g. for years that’ll be 4, or 6 if you’re into history or futurology). But sometimes that ’natural upper bound’ is something very large and unusual like 1<<64. So how much space do you really need for your numbers? You’ve got to walk all the numbers in the column, find the extremes, and see how big they are.

How do you see how big they are? Well, you could just check the length of its fmt.Sprint. Easy!

func lenFmt[V constraints.Unsigned](n V) V {
	return V(len(fmt.Sprint(n)))

However, if you’re suspicious of fmt vis-à-vis its performance and/or memory usage (and you probably should be) you might want to tweak it a little, without changing the ‘algorithm’,

func lenStrconv[V constraints.Unsigned](n V) V {
	return V(len(strconv.FormatUint(uint64(n), 10)))

Now, the length of the decimal representation of an integer u is just ⌊log₁₀u⌋+1 (with a special case for 0). So you could do just that,

func lenMath[V constraints.Unsigned](n V) V {
	return V(math.Floor(math.Log10(float64(n)))) + 1

or … you could use integer math to do ⌊log₁₀u⌋. Which is what I’ve done in intmath.Len.

$ go test -short -bench Len -benchmem
goos: linux
goarch: amd64
cpu: 12th Gen Intel(R) Core(TM) i5-12600H
BenchmarkLenFmt-12        	21367644	       49.89 ns/op	     16 B/op	      1 allocs/op
BenchmarkLenStrconv-12    	76015239	       15.01 ns/op	      7 B/op	      0 allocs/op
BenchmarkLenMath-12       	179116516	        6.703 ns/op	      0 B/op	      0 allocs/op
BenchmarkLenInt-12        	1000000000	        0.3558 ns/op	  0 B/op	      0 allocs/op
ok	5.826s is very much still a work in progress, but the idea is for it to complement math/bits so that together they can do most of the boring stuff from math, but using algorithms that work faster for integers.