4
\$\begingroup\$

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")
}

Here is also a link to the playground

200_success
146k22 gold badges190 silver badges479 bronze badges
asked May 6, 2014 at 10:16
\$\endgroup\$
0

1 Answer 1

3
\$\begingroup\$

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)
}
answered May 15, 2014 at 2:11
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.