As part of a simple (naive) internationalization attempt in Go, I am trying to come up with a number formatting routine with customizable decimal and thousands separator.
Is this approach alright?
var decLen = 1
var decSep = "."
var thouLen = 1
var thouSep = ","
func numberFormat(floatVal float64, precision int) string {
intPart := int(math.Floor(math.Abs(floatVal)))
var decimalPart int
// estimate length of str
var dec int
if floatVal == 0 {
dec = 1
} else {
dec = (int)(math.Log10(math.Abs(floatVal))) + 1
}
thou := dec / 3
// dec / 3 are thousands groups
size := dec + (thou * thouLen)
if precision > 0 {
// dec sep + precision
size += decLen + precision
// (floatVal - intPart) * 10 ^ precision
decimalPartFloat := (math.Abs(floatVal) - math.Floor(math.Abs(floatVal))) * math.Pow(10.0, float64(precision))
decimalPart = int(math.Floor(decimalPartFloat + 0.5))
}
// negative
if floatVal < 0 {
size += 1
}
thouGroups := make([]int, 0, thou+1)
if intPart == 0 {
thouGroups = append(thouGroups, 0)
} else {
i := intPart
for i != 0 {
thouGroups = append(thouGroups, i%1000)
i = i / 1000
}
}
b := make([]byte, 0, size)
if floatVal < 0 {
b = append(b, '-')
}
for i := len(thouGroups) - 1; i >= 0; i-- {
b = append(b, []byte(strconv.Itoa(thouGroups[i]))...)
if i != 0 {
b = append(b, []byte(thouSep)...)
}
}
if precision > 0 {
b = append(b, []byte(decSep)...)
b = append(b, []byte(strconv.Itoa(decimalPart))...)
var decimalLen int
if decimalPart != 0 {
decimalLen = (int)(math.Log10(float64(decimalPart))) + 1
} else {
decimalLen = 1
}
if decimalLen < precision {
// pad zeros
b = append(b, []byte(strings.Repeat("0", precision-decimalLen))...)
}
}
// remove NUL bytes
return strings.Trim(string(b), "\x00")
}
1 Answer 1
Your approach is ok, but I think it could make better use of some of the existing library functionality. Also, this looks like something that should be a user-facing function, so let's rename it to NumberFormat
(since anything starting with a lower case letter is not exported from a package in Go).
However, let's back up for a little bit. Code to do number to string conversions can have quite a number of corner cases, and can be quite difficult to get correct in all those cases. For a function like this, I think using Test-Driven Development (TDD) would be ideal. Go's testing
package makes this fairly pain-free. First thing would be to define a number of corner cases to test. Here's some I can think of:
- The number 0
- A short number, with less than 3 digits before the decimal point
- A number with only a fractional part specified
- A negative number
- An "overspecified" format (that is, more decimal places specified than decimal places)
- A number with no fractional part (integers)
This is just a quick list off the top of my head. Let's turn them into test cases:
package numformat
import (
"testing"
)
func TestNegPrecision(test *testing.T) {
s := NumberFormat(123456.67895414134, -1)
if s != "123,456" {
test.Fatalf("Number format failed on negative precision test\n")
}
}
func TestShort(test *testing.T) {
s := NumberFormat(34.33384, 1)
expected := "34.3"
if s != expected {
test.Fatalf("Number format failed short test: Expected: %s, " +
"Actual: %s\n", expected, s)
}
}
func TestOverSpecified(test *testing.T) {
s := NumberFormat(9432.839, 5)
expected := "9,432.83900"
if s != expected {
test.Fatalf("Number format failed over specified test: Expected %s, " +
"Actual: %s\n", expected, s)
}
}
func TestZero(test *testing.T) {
s := NumberFormat(0, 0)
expected := "0"
if s != expected {
test.Fatalf("Number format failed short test: Expected: %s, " +
"Actual: %s\n", expected, s)
}
}
func TestNegative(test *testing.T) {
s := NumberFormat(-348932.34989, 4)
expected := "-348,932.3499"
if s != expected {
test.Fatalf("Number format failed short test: Expected: %s, " +
"Actual: %s\n", expected, s)
}
}
func TestOnlyDecimal(test *testing.T) {
s := NumberFormat(.349343, 3)
expected := "0.349"
if s != expected {
test.Fatalf("Number format failed short test: Expected: %s, " +
"Actual: %s\n", expected, s)
}
}
Running these with go test
and your implementation passes, so that's good.
Now, to look at the implementation. The first thing is that the strconv
package has a FormatFloat
function that could do a chunk of the work here for you. Likewise, strings.SplitString
is another function that can take care of some of the work. Here's a different implementation that uses these, as well as slices to do the work instead:
var thousand_sep = byte(',')
var dec_sep = byte('.')
// Parses the given float64 into a string, using thousand_sep to separate
// each block of thousands before the decimal point character.
func NumberFormat(val float64, precision int) string {
// Parse the float as a string, with no exponent, and keeping precision
// number of decimal places. Note that the precision passed in to FormatFloat
// must be a positive number.
use_precision := precision
if precision < 1 { use_precision = 1 }
as_string := strconv.FormatFloat(val, 'f', use_precision, 64)
// Split the string at the decimal point separator.
separated := strings.Split(as_string, ".")
before_decimal := separated[0]
// Our final string will need a total space of the original parsed string
// plus space for an additional separator character every 3rd character
// before the decimal point.
with_separator := make([]byte, 0, len(as_string) + (len(before_decimal) / 3))
// Deal with a (possible) negative sign:
if before_decimal[0] == '-' {
with_separator = append(with_separator, '-')
before_decimal = before_decimal[1:]
}
// Drain the initial characters that are "left over" after dividing the length
// by 3. For example, if we had "12345", this would drain "12" from the string
// append the separator character, and ensure we're left with something
// that is exactly divisible by 3.
initial := len(before_decimal) % 3
if initial > 0 {
with_separator = append(with_separator, before_decimal[0 : initial]...)
before_decimal = before_decimal[initial:]
if len(before_decimal) >= 3 {
with_separator = append(with_separator, thousand_sep)
}
}
// For each chunk of 3, append it and add a thousands separator,
// slicing off the chunks of 3 as we go.
for len(before_decimal) >= 3 {
with_separator = append(with_separator, before_decimal[0 : 3]...)
before_decimal = before_decimal[3:]
if len(before_decimal) >= 3 {
with_separator = append(with_separator, thousand_sep)
}
}
// Append everything after the '.', but only if we have positive precision.
if precision > 0 {
with_separator = append(with_separator, dec_sep)
with_separator = append(with_separator, separated[1]...)
}
return string(with_separator)
}