Search code examples
cformatprintfnumber-formatting

sprintf formatting: fixed number of characters, variable numbers of decimals


I am looking for a sprintf-like format string that would format float numbers with a variable number of decimals, but a fixed total number of characters (say, 3), in order to provide maximum information. For instance:

0      ->  `.00` or `0.0` or `  0`
0.124  ->  `.12`
0.357  ->  `.36`
1.788  ->  `1.8`
9.442  ->  `9.4`
10.25  ->  `10.` or ` 10`
75.86  ->  `76.` or ` 76`
99.44  ->  `99.` or ` 99`
100.0  ->  `100`

(yes, my numbers will all be 0-to-100 floats)

How to achieve this?
Is this kind of fixed-width formatting implemented in sprintf format string language?


Solution

  • Is this kind of fixed-width formatting implemented in sprintf format string language?

    No.

    How to achieve this?

    To meet OP's goals, code can analyze the range of the float f to attempt to print with maximal information.

    This begins with "%.*f" to control the number of digits following the ..

    A common pitfall occurs with values just under a power of 10 that round up and cause another digit in the output.

    Code could try to test against f < 0.995f rather than f < 1.0 for example, yet this leads into failed corner cases given the binary nature of floating point.

    Better to test the range against an exact constant like 1.0, 10.0, ...

    The below tries, at most twice, to sprintf(). The 2nd attempt occurs with those pesky values just under a power of 10.

    #include <assert.h>
    #include <stdio.h>
    #include <stdbool.h>
    #include <string.h>
    
    void iago_sprint1(char *dest, int len, float f) {
      if (f < 1.0) {
        char buf[len + 2];
        //printf("p1\n");
        snprintf(buf, sizeof buf, "%.*f", len - 1, f);
        if (buf[0] == '0') {
          strcpy(dest, buf + 1);
          return;
        }
      }
      float limit = 10.0;
      int prec = len - 2;
    
      while (prec >= -1) {
        if (f < limit) {
          char buf[len + 2];
          //printf("p2\n");
          int cnt = snprintf(buf, sizeof buf, "%.*f", prec < 0 ? 0: prec, f);
          if (cnt <= len) {
            strcpy(dest, buf);
            return;
          }
        }
        prec--;
        limit *= 10;
      }
      assert(0); // `f` was out of range
    }
    
    #define N 3
    int main(void) {
      float f[] = {0, 0.124f, 0.357f, 1.788f, 9.442f, 10.25f, 75.86f, 99.44f,
          100.0f, 99.999f, 9.9999f, .99999f, .099999f, 1.04f, 1.06f};
      char line[N + 1];
    
      for (unsigned i = 0; i < sizeof f / sizeof *f; i++) {
        //snprintf(line, sizeof line, "%#*g", N, f[i]);
        //puts(line);
    
        iago_sprint1(line, N, f[i]);
        puts(line);
      }
    }
    

    Output

    .00
    .12
    .36
    1.8
    9.4
    10
    76
    99
    100
    100
    10
    1.0
    .10
    1.0
    1.1
    

    Pre-calculating precision needs pitfall

    Should code attempt to pre-calculate the precision needed for only 1 sprintf() call, the calculation needs to deduce precision just as sprintf() does - in effect code is doing sprint()` work over again.

    Consider len==3 and float x = 9.95f;. As a binary float does not represent that exactly, it instead has a value just above or below 9.951. If it is below, the string should be "9.9", if it is above, "10.". If code had double x = 9.95; (again not exactly representable) the output might differ. If code used a float, but FLT_EVAL_MODE > 1, the actual value passed might not be the expected float 9.95.

    Precision-->  .1   .0 
    9.94001...   "9.9"  "10."
    9.94999...   "9.9"  "10." // Is this 9.95f
    9.95001...   "10.0" "10." // or this?
    9.95999...   "10.0" "10."
    

    New and improved

    I re-worked and simplified code - after acceptance.

    The trick is to print with "%*.f" a calculated precision based on the power-of-10 of f to a buffer one wider than the goal - assuming no carry into another digit due to rounding.

    The power-of-10 calculation can be done exactly with a small loop.

    When the leading character is a 0, it is not needed as in "0.xx" --> ".xx".

    Otherwise with no carry into another digit due to rounding, the string fits and we are done.

    Otherwise a carry, then the last character is either a '.' or '0' after the decimal point and so not needed. This happens when f is just below a power of 10, but the printed version rounds up to that power of 10. And so only copy length-1 digits.

    // `len` number of characters to print
    // `len+1` is the size of `dest`
    void iago_sprint3(char *dest, int len, float f) {
      assert(len >= 1);
      int prec = len - 1;
      float power10 = 1.0;
      while (f >= power10 && prec > 0) {
        power10 *= 10;
        prec--;
      }
    
      char buf[len + 2];
      int cnt = snprintf(buf, sizeof buf, "%.*f", prec, f);
      assert (cnt >= 0 && cnt <= len + 1);
      if (buf[0] == '0') {
          strcpy(dest, buf + 1);
          return;
        }
      strncpy(dest, buf, (unsigned) len);
      dest[len] = 0;
    }
    

    1 Typical float 9.95f is exactly

    9.94999980926513671875    
    

    Typical double 9.95 is exactly

    9.949999999999999289457264239899814128875732421875