HiveBrain v1.2.0
Get Started
← Back to all entries
patterngoMinor

Number formatting

Submitted by: @import:stackexchange-codereview··
0
Viewed 0 times
formattingnumberstackoverflow

Problem

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; 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

Solution

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

Code Snippets

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

Context

StackExchange Code Review Q#49055, answer score: 3

Revisions (0)

No revisions yet.