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

\$\endgroup\$
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)
}
| improve this answer | |
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.