go

Go byte units and localized formatting

Reading time of 2297 words
11 minutes
Reading time of 2297 words ~ 11 minutes


Did you find this article helpful?
Please consider tipping me a coffee as a thank you.
Ko-fi Buy Me a Coffee
Did you find this article helpful? Please consider tipping me a coffee or three as a thank you.
Tip using Ko-fi or Buy Me a Coffee

This article will go through creating byte unit methods for both metric and binary prefixes. The two stringers will take a float64 value and round it with a metric unit.

  • Decimal(15000).String() returns 15.0 kB
  • Binary(15000).String() returns 14.6 KiB

Unlike many other byte converters, this will also format the number using international digit separators. While numbers are universal, formatting is not.

For example, 15000.55 can be displayed differently depending on the country or language.

English: 15,000.55

Germany: 15.000,55

France: 15 000,55

Even if your program is primarily in English, it could be nice to allow users to localize numbers and byte units.

You can find the final result on GitHub or run it at Go Playground.

Localized separators

First, create a new global float64 named Decimal and a String method to convert any float64 values to a string.

package main

type Decimal float64

func (n Decimal) String() string {
    return ""
}

Set up the main function with a placeholder number to print.

func main() {
    s := Decimal(1000).String()
    fmt.Println(s)
}

Running this is pointless as it will return a blank newline.

To make this string method useful, let’s apply language support using the Go text extended libraries. First, import the language package and create a t language.Tag parameter for String().

import (
    "fmt"

    "golang.org/x/text/language"
)
func (n Decimal) String(t language.Tag) string {
    return ""
}

Add a new p message printer that formats our float64 value to the supplied language.

import (
    "fmt"

    "golang.org/x/text/language"
    "golang.org/x/text/message"
)
func (n Decimal) String(t language.Tag) string {
    p := message.NewPrinter(t)
    // round the n float to 2 precision points
    return p.Sprintf("%.2f", n)
}

Finally, update the main function to use the t language Tag parameters, using English, German and French.

func main() {
    const n = 1000.01
    en := Decimal(n).String(language.English)
    de := Decimal(n).String(language.German)
    fr := Decimal(n).String(language.French)
    fmt.Println(en)
    fmt.Println(de)
    fmt.Println(fr)
}

The result so far can be run in Go.

 1package main
 2
 3import (
 4    "fmt"
 5
 6    "golang.org/x/text/language"
 7    "golang.org/x/text/message"
 8)
 9
10type Decimal float64
11
12func (n Decimal) String(t language.Tag) string {
13    p := message.NewPrinter(t)
14    // round the n float to 2 precision points
15    return p.Sprintf("%.2f", n)
16}
17
18func main() {
19    const n = 1000.01
20    en := Decimal(n).String(language.English)
21    de := Decimal(n).String(language.German)
22    fr := Decimal(n).String(language.French)
23    fmt.Println(en)
24    fmt.Println(de)
25    fmt.Println(fr)
26}
$ go run .
1,000.01
1.000,01
1 000,01

Decimal units

Below the Decimal type, create a new kb const with a Decimal float64 type and a value of 1000.

type Decimal float64

const kb Decimal = 1000

Back to the String method, we will implement the kB unit. Create an x variable for switch case usage and a copy of the n Decimal float to the f variable. Finally, update the return to use the f variable.

1func (n Decimal) String(t language.Tag) string {
2    p := message.NewPrinter(t)
3    f := n
4    x := n
5    switch {}
6    // round the n float to 2 precision points
7    return p.Sprintf("%.2f", f)
8}

Update the switch to handle any x value greater or equal to the kb const and move the p message printer to the default case.

switch {
    case x >= kb:
        // do nothing
    default:
        // round the n float to 2 precision points
        return p.Sprintf("%.2f", f)
}

In the switch case, divide the f Decimal value by the kb const and return the result. The Sprintf method also should return the kilobyte unit symbol.

f /= kb is the same as f = f / kb.

1switch {
2    case x >= kb:
3        f /= kb
4        // round the n float to 1 precision point
5        return p.Sprintf("%.1f %s", f, "kB")
6    default:
7        // round the n float to 2 precision points
8        return p.Sprintf("%.2f", f)
9}

We need to remove the two precision points from the default case as it returns the value as bytes, which display as integers, not precision floats.

default:
    return p.Sprintf("%.0f", f)

The result so far should convert any byte value 1000 or greater into Kilobytes with one precision point. All other values get kept as bytes.

package main

import (
    "fmt"

    "golang.org/x/text/language"
    "golang.org/x/text/message"
)

type Decimal float64

const kb Decimal = 1000

func (n Decimal) String(t language.Tag) string {
    p := message.NewPrinter(t)
    f := n
    x := n
    switch {
    case x >= kb:
        f /= kb
        // round the n float to 1 precision point
        return p.Sprintf("%.1f %s", f, "kB")
    default:
        return p.Sprintf("%.0f", f)
    }
}

func main() {
    const n = 1000.01
    en := Decimal(n).String(language.English)
    de := Decimal(n).String(language.German)
    fr := Decimal(n).String(language.French)
    fmt.Println(en)
    fmt.Println(de)
    fmt.Println(fr)
    fmt.Println(Decimal(123).String(language.Arabic))
}
$ go run .
1.0 kB
1,0 kB
1,0 kB
١٢٣

You can play around changing the n const value. But the switch logic has a problem. Whenever the n value is negative, the String method will display it as bytes.

const n = -1000.01
$ go run .
-1,000

To negate this, update the x var to save the absolute value of n.

1import (
2    "fmt"
3    "math"
4)
5
6func (n Decimal) String(t language.Tag) string {
7    p := message.NewPrinter(t)
8    f := n
9    x := Decimal(math.Abs(float64(n)))
$ go run .
-1.0 kB

Let’s add support for Megabytes. Create a new mb Decimal const with a value of one million.

1const (
2    kb Decimal = 1_000
3    mb Decimal = 1_000_000
4)

Add a new case in the switch above the existing kb case to handle n values greater or equal to mb. All units that are equal or larger than a Megabyte will use all caps symbols and two precision points.

 1switch {
 2case x >= mb:
 3    f /= mb
 4    // round the n float to 2 precision points
 5    return p.Sprintf("%.2f %s", f, "MB")
 6case x >= kb:
 7    f /= kb
 8    // round the n float to 1 precision point
 9    return p.Sprintf("%.1f %s", f, "kB")
10default:
11    return p.Sprintf("%.0f", f)
12}

To test the new case, change the n value in main to a byte value larger than a million.

func main() {
    const n = 9_876_543
$ go run .
9.88 MB

Now we will finish up the method to support units up to Exabytes (EB). In the Decimal const collection, add variables gb, tb, pb, and eb. Instead of using super long integer values, use the more manageable scientific notation.

const (
    kb Decimal = 1e+3
    mb Decimal = 1e+6
    gb Decimal = 1e+9
    tb Decimal = 1e+12
    pb Decimal = 1e+15
    eb Decimal = 1e+18
)

If these floats seem cryptic, consider the values as the digit 1 with x number of zeros.

kB = 1e+3 is a 1 combined with three zeros to make 1000.

MB = 1e+6 is 1 combined with six zeros to make 1000000.

Create three constants to hold the various floating-point formats with none, one, and two precision points.

const (
    precision0 = "%.0f"
    precision1 = "%.1f\u00A0%s"
    precision2 = "%.2f\u00A0%s"
)

%f prints the float value.

%s prints the unit symbols (kB, MB, etc.).

\u00A0 is Unicode codepoint 160, no-break space to ensure the unit symbol is always with the float value.

The const precision0 is for byte values, so there is no need for a unit symbol.

In the switch, replace the p.Sprintf formats with the precision consts.

switch {
case x >= mb:
    f /= mb
    return p.Sprintf(precision2, f, "MB")
case x >= kb:
    f /= kb
    return p.Sprintf(precision1, f, "kB")
default:
    return p.Sprintf(precision0, f)
}

Complete the switch with cases to support EB, PB, TB, and GB units in descending order.

switch {
case x >= eb:
    f /= eb
    return p.Sprintf(precision2, f, "EB")
case x >= pb:
    f /= pb
    return p.Sprintf(precision2, f, "PB")
case x >= tb:
    f /= tb
    return p.Sprintf(precision2, f, "TB")
case x >= gb:
    f /= gb
    return p.Sprintf(precision2, f, "GB")
case x >= mb:
    f /= mb
    return p.Sprintf(precision2, f, "MB")
case x >= kb:
    f /= kb
    return p.Sprintf(precision1, f, "kB")
default:
    return p.Sprintf(precision0, f)
}

Try out some silly large n byte values and run the results.

func main() {
    const n = 92233720368547758079223
    en := language.English
    de := language.German
    fr := language.French
    fmt.Println("English", Decimal(n).String(en))
    fmt.Println("German", Decimal(n).String(de))
    fmt.Println("French", Decimal(n).String(fr))
}
$ go run .
English 92,233.72 EB
German 92.233,72 EB
French 92 233,72 EB

Binary units

Creating the Binary method will be much quicker as it’s very similar to the Decimal method. The main difference is while the Decimal uses Base-10 metrics (KB = 1000), the Binary uses Base-2 metrics (KiB = 1024).

Create a new Binary type and constants for the binary bytes. These will rely on the left shift operator.

type (
    Decimal float64
    Binary  float64
)

const (
    kib Binary = 1 << 10
    mib Binary = 1 << 20
    gib Binary = 1 << 30
    tib Binary = 1 << 40
    pib Binary = 1 << 50
    eib Binary = 1 << 60
)

If these are confusing, left-shifting implements powers of 2.

  • 1 << 1 equates to 2
  • 1 << 2 equates to 2 _ 2 = 4
  • 1 << 3 equates to 2 _ 2 _ 2 = 8
  • 1 << 10 equates to 2 _ 2 _ 2 _ 2 _ 2 _ 2 _ 2 _ 2 _ 2 _ 2 = 1,024
  • 1 << 20 equates to 1024 _ 1024 = 1,048,576
  • 1 << 30 equates to 1024 _ 1024 \* 1024 = 1,073,741,824

Create a duplicate of the Decimal String method but replace the Decimal with Binary types.

 1func (n Binary) String(t language.Tag) string {
 2    p := message.NewPrinter(t)
 3    f := n
 4    x := Binary(math.Abs(float64(n)))
 5    switch {
 6    case x >= eb:
 7        f /= eb
 8        return p.Sprintf(precision2, f, "EB")
 9    case x >= pb:
10        f /= pb
11        return p.Sprintf(precision2, f, "PB")
12    case x >= tb:
13        f /= tb
14        return p.Sprintf(precision2, f, "TB")
15    case x >= gb:
16        f /= gb
17        return p.Sprintf(precision2, f, "GB")
18    case x >= mb:
19        f /= mb
20        return p.Sprintf(precision2, f, "MB")
21    case x >= kb:
22        f /= kb
23        return p.Sprintf(precision1, f, "kB")
24    default:
25        return p.Sprintf(precision0, f)
26    }
27}

Finally, update the switch cases to use the binary constants and return binary units.

 1func (n Binary) String(t language.Tag) string {
 2    p := message.NewPrinter(t)
 3    f := n
 4    x := Binary(math.Abs(float64(n)))
 5    switch {
 6    case x >= eib:
 7        f /= eib
 8        return p.Sprintf(precision2, f, "EiB")
 9    case x >= pib:
10        f /= pib
11        return p.Sprintf(precision2, f, "PiB")
12    case x >= tib:
13        f /= tib
14        return p.Sprintf(precision2, f, "TiB")
15    case x >= gib:
16        f /= gib
17        return p.Sprintf(precision2, f, "GiB")
18    case x >= mib:
19        f /= mib
20        return p.Sprintf(precision2, f, "MiB")
21    case x >= kib:
22        f /= kib
23        return p.Sprintf(precision1, f, "KiB")
24    default:
25        return p.Sprintf(precision0, f)
26    }
27}

Let’s wrap it up and test it out by adding some Binary methods in the main func.

  1package main
  2
  3import (
  4    "fmt"
  5    "math"
  6
  7    "golang.org/x/text/language"
  8    "golang.org/x/text/message"
  9)
 10
 11type (
 12    Decimal float64
 13    Binary  float64
 14)
 15
 16const (
 17    kb Decimal = 1e+03
 18    mb Decimal = 1e+06
 19    gb Decimal = 1e+09
 20    tb Decimal = 1e+12
 21    pb Decimal = 1e+15
 22    eb Decimal = 1e+18
 23)
 24
 25const (
 26    kib Binary = 1 << 10
 27    mib Binary = 1 << 20
 28    gib Binary = 1 << 30
 29    tib Binary = 1 << 40
 30    pib Binary = 1 << 50
 31    eib Binary = 1 << 60
 32)
 33
 34const (
 35    precision0 = "%.0f"
 36    precision1 = "%.1f\u00A0%s"
 37    precision2 = "%.2f\u00A0%s"
 38)
 39
 40func main() {
 41    const n = 10000000000
 42    en := language.English
 43    de := language.German
 44    fr := language.French
 45    fmt.Println("English", Decimal(n).String(en))
 46    fmt.Println("English", Binary(n).String(en))
 47    fmt.Println("German", Decimal(n).String(de))
 48    fmt.Println("German", Binary(n).String(de))
 49    fmt.Println("French", Decimal(n).String(fr))
 50    fmt.Println("French", Binary(n).String(fr))
 51}
 52
 53func (n Decimal) String(t language.Tag) string {
 54    p := message.NewPrinter(t)
 55    f := n
 56    x := Decimal(math.Abs(float64(n)))
 57    switch {
 58    case x >= eb:
 59        f /= eb
 60        return p.Sprintf(precision2, f, "EB")
 61    case x >= pb:
 62        f /= pb
 63        return p.Sprintf(precision2, f, "PB")
 64    case x >= tb:
 65        f /= tb
 66        return p.Sprintf(precision2, f, "TB")
 67    case x >= gb:
 68        f /= gb
 69        return p.Sprintf(precision2, f, "GB")
 70    case x >= mb:
 71        f /= mb
 72        return p.Sprintf(precision2, f, "MB")
 73    case x >= kb:
 74        f /= kb
 75        return p.Sprintf(precision1, f, "kB")
 76    default:
 77        return p.Sprintf(precision0, f)
 78    }
 79}
 80
 81func (n Binary) String(t language.Tag) string {
 82    p := message.NewPrinter(t)
 83    f := n
 84    x := Binary(math.Abs(float64(n)))
 85    switch {
 86    case x >= eib:
 87        f /= eib
 88        return p.Sprintf(precision2, f, "EiB")
 89    case x >= pib:
 90        f /= pib
 91        return p.Sprintf(precision2, f, "PiB")
 92    case x >= tib:
 93        f /= tib
 94        return p.Sprintf(precision2, f, "TiB")
 95    case x >= gib:
 96        f /= gib
 97        return p.Sprintf(precision2, f, "GiB")
 98    case x >= mib:
 99        f /= mib
100        return p.Sprintf(precision2, f, "MiB")
101    case x >= kib:
102        f /= kib
103        return p.Sprintf(precision1, f, "KiB")
104    default:
105        return p.Sprintf(precision0, f)
106    }
107}
$ go run .
English 10.00 GB
English 9.31 GiB
German 10,00 GB
German 9,31 GiB
French 10,00 GB
French 9,31 GiB

Congratulations, you now have working Binary and Decimal methods. I’ve also written a collection of unit tests for both. You can find the code on GitHub or run it at Go Playground.


Written by Ben Garrett

Did you find this article helpful?
Please consider tipping me a coffee as a thank you.
Ko-fi Buy Me a Coffee
Did you find this article helpful? Please consider tipping me a coffee or three as a thank you.
Tip using Ko-fi or Buy Me a Coffee