In common lisp how can I format a floating point and specify grouping, group char and decimal separator char
Asked Answered
O

5

8

Let's say I have the floating point number 1234.9

I want to format it as 1.234,90

Is there a format directive combination for that? ~D ,which can handle the grouping and the group char, handles only integers. ~F doesn't handle grouping at all. And none as far as I know can change the decimal point from . to ,

The only solution I see is to use ~D for the integer part digit grouping and concatenate it with , and the decimal part. Any better ideas?

Ottoottoman answered 26/1, 2016 at 11:32 Comment(1)
I think the best way to do it is to write your own function that can be used with the ~/func/ directive.Elaelaborate
T
6

You can define a function to be called with tilde-slash, which most of the other answers have already done, but in order to get output similar to ~F, but with comma chars injected, and with the decimal point replaced, I think it's best to call get the output produced by ~F, and then modify it and write it to the string. Here's a way to do that, using a utility inject-comma that adds a comma character at specified intervals to a string. Here's the directive function:

(defun print-float (stream arg colonp atp
                    &optional
                      (point-char #\.)
                      (comma-char #\,)
                      (comma-interval 3))
  "A function for printing floating point numbers, with an interface
suitable for use with the tilde-slash FORMAT directive.  The full form
is 

    ~point-char,comma-char,comma-interval/print-float/

The point-char is used in place of the decimal point, and defaults to
#\\.  If : is specified, then the whole part of the number will be
grouped in the same manner as ~D, using COMMA-CHAR and COMMA-INTERVAL.
If @ is specified, then the sign is always printed."
  (let* ((sign (if (minusp arg) "-" (if (and atp (plusp arg)) "+" "")))
         (output (format nil "~F" arg))
         (point (position #\. output :test 'char=))
         (whole (subseq output (if (minusp arg) 1 0) point))
         (fractional (subseq output (1+ point))))
    (when colonp
      (setf whole (inject-comma whole comma-char comma-interval)))
    (format stream "~A~A~C~A"
            sign whole point-char fractional)))

Here are some examples:

(progn 
  ;; with @ (for sign) and : (for grouping)
  (format t "~','.2@:/print-float/ ~%" 12345.6789) ;=> +1.23.45,679

  ;; with no @ (no sign) and : (for grouping)
  (format t "~'.'_3:/print-float/ ~%" 12345.678)   ;=>  12_345.678

  ;; no @ (but sign, since negative) and : (for grouping)
  (format t "~'.'_3:/print-float/ ~%" -12345.678)  ;=> -12_345.678

  ;; no @ (no sign) and no : (no grouping)
  (format t "~'.'_3@/print-float/ ~%" 12345.678))  ;=> +12345.678 (no :)

Here are the examples from coredump-'s answer, which actually helped me catch a bug with negative numbers:

CL-USER> (loop for i in '(1034.34 -223.12 -10.0 10.0 14 324 1020231)
            do (format t "~','.:/print-float/~%" i))
1.034,34
-223,12
-10,0
10,0
14,0
324,0
1.020.231,0
NIL

Here's inject-comma, with some examples:

(defun inject-comma (string comma-char comma-interval)
  (let* ((len (length string))
         (offset (mod len comma-interval)))
    (with-output-to-string (out)
      (write-string string out :start 0 :end offset)
      (do ((i offset (+ i comma-interval)))
          ((>= i len))
        (unless (zerop i)
          (write-char comma-char out))
        (write-string string out :start i :end (+ i comma-interval))))))
(inject-comma "1234567" #\, 3)
;;=> "1,234,567"

(inject-comma "1234567" #\. 2)
;;=> "1.23.45.67"
Tetra answered 26/1, 2016 at 16:27 Comment(3)
There are a few possible improvements that could happen here: since there's a fair amount of string processing, some strings could be preallocated. E.g., in the inject-comma, we can compute in advance how long the output string would be, and we can compute it in advance. Similarly, rather than using format a second time to join up the strings, we could simply concatenate them and then write the string to the stream. I can implement those if anyone feels they're necessary, but I think it will make the code a bit less clear for casual reading.Tetra
This works with sbcl, but not with Clozure Common Lisp, any idea why? Bug in Clozure?Indicatory
@Joshua Taylor Blows up with invalid format string error on any example you providedIndicatory
C
6

As the comment of jkiiski suggests, you could use the ~/func/ directive.

This is just an example, you can elaborate more with the function:

CL-USER> (defun q(stream arg &rest args)
           (declare (ignore args))
           (format stream 
                   "~,,'.,:D,~a" 
                   (truncate arg)
                   (let ((float-string (format nil "~f" arg)))
                     (subseq float-string (1+ (position #\. float-string))))))
Q
CL-USER> (format t "~/q/~%" 1024.36)
1.024,36
NIL
CL-USER> (format t "~/q/~%" -1024.36)
-1.024,36
NIL

Edited

The first version had round, which is wrong, truncate is the right operator to use.

Clariceclarie answered 26/1, 2016 at 14:36 Comment(3)
This doesn't work. E.g., (format t "~/q/~%" 1034.56) => 1.035,56 which is 1 greater than the input.Tetra
Corrected, thanks, I usually chose the wrong function among round, floor, ceiling, truncate, etc.Clariceclarie
I have a more plain issue and piggy on this question. It is about printing xxx,xxx,xxx.xx for dollar value. And I change the first format to "~;D.~a" and "~2f" it works!!! Thanks. It is so hard to print $ value in Common Lisp it is just unacceptable.Sluiter
T
6

You can define a function to be called with tilde-slash, which most of the other answers have already done, but in order to get output similar to ~F, but with comma chars injected, and with the decimal point replaced, I think it's best to call get the output produced by ~F, and then modify it and write it to the string. Here's a way to do that, using a utility inject-comma that adds a comma character at specified intervals to a string. Here's the directive function:

(defun print-float (stream arg colonp atp
                    &optional
                      (point-char #\.)
                      (comma-char #\,)
                      (comma-interval 3))
  "A function for printing floating point numbers, with an interface
suitable for use with the tilde-slash FORMAT directive.  The full form
is 

    ~point-char,comma-char,comma-interval/print-float/

The point-char is used in place of the decimal point, and defaults to
#\\.  If : is specified, then the whole part of the number will be
grouped in the same manner as ~D, using COMMA-CHAR and COMMA-INTERVAL.
If @ is specified, then the sign is always printed."
  (let* ((sign (if (minusp arg) "-" (if (and atp (plusp arg)) "+" "")))
         (output (format nil "~F" arg))
         (point (position #\. output :test 'char=))
         (whole (subseq output (if (minusp arg) 1 0) point))
         (fractional (subseq output (1+ point))))
    (when colonp
      (setf whole (inject-comma whole comma-char comma-interval)))
    (format stream "~A~A~C~A"
            sign whole point-char fractional)))

Here are some examples:

(progn 
  ;; with @ (for sign) and : (for grouping)
  (format t "~','.2@:/print-float/ ~%" 12345.6789) ;=> +1.23.45,679

  ;; with no @ (no sign) and : (for grouping)
  (format t "~'.'_3:/print-float/ ~%" 12345.678)   ;=>  12_345.678

  ;; no @ (but sign, since negative) and : (for grouping)
  (format t "~'.'_3:/print-float/ ~%" -12345.678)  ;=> -12_345.678

  ;; no @ (no sign) and no : (no grouping)
  (format t "~'.'_3@/print-float/ ~%" 12345.678))  ;=> +12345.678 (no :)

Here are the examples from coredump-'s answer, which actually helped me catch a bug with negative numbers:

CL-USER> (loop for i in '(1034.34 -223.12 -10.0 10.0 14 324 1020231)
            do (format t "~','.:/print-float/~%" i))
1.034,34
-223,12
-10,0
10,0
14,0
324,0
1.020.231,0
NIL

Here's inject-comma, with some examples:

(defun inject-comma (string comma-char comma-interval)
  (let* ((len (length string))
         (offset (mod len comma-interval)))
    (with-output-to-string (out)
      (write-string string out :start 0 :end offset)
      (do ((i offset (+ i comma-interval)))
          ((>= i len))
        (unless (zerop i)
          (write-char comma-char out))
        (write-string string out :start i :end (+ i comma-interval))))))
(inject-comma "1234567" #\, 3)
;;=> "1,234,567"

(inject-comma "1234567" #\. 2)
;;=> "1.23.45.67"
Tetra answered 26/1, 2016 at 16:27 Comment(3)
There are a few possible improvements that could happen here: since there's a fair amount of string processing, some strings could be preallocated. E.g., in the inject-comma, we can compute in advance how long the output string would be, and we can compute it in advance. Similarly, rather than using format a second time to join up the strings, we could simply concatenate them and then write the string to the stream. I can implement those if anyone feels they're necessary, but I think it will make the code a bit less clear for casual reading.Tetra
This works with sbcl, but not with Clozure Common Lisp, any idea why? Bug in Clozure?Indicatory
@Joshua Taylor Blows up with invalid format string error on any example you providedIndicatory
C
2

If you don't mind splitting integer and fractional part, you can do the following:

(multiple-value-bind (int rest) (floor 1234.56)
   (let ((rest (round (* rest 1000))))
      (format t "~,,'.,:D,~D~%" int rest)))

1.234,560

The multiplication before rounding tells how many digits after comma you would like to print. Not sure if this approach lands itself nicely into automatic control of precision printing, i.e. 1.5 printed as "1,5" and not as "1,500".

Cumulonimbus answered 26/1, 2016 at 14:49 Comment(3)
This won't work consistently because of the floor semantics. E.g., (floor 125.56) => 125, 0.55999756, so that second ~D will probably produce unexpected results.Tetra
@JoshuaTaylor I see the problem, but I think round will absorb it. For 125.56 it will still produce "125,560". It breaks only if you try to put too many digits. But, it will break for negative numbers, in which case one may use ceiling (with the same limitations). So, it won't be that simple...Cumulonimbus
I think that truncate would be a better option, since it always goes towards zero. You still end up with the behavior that you get two separate numbers though, and the fractional part may be "different" from what you'd see in the original number after the decimal point.Tetra
E
1

Other answers currently use round, which is probably not the intended behavior when rounding up (positive numbers) or down (negative numbers). Here is another approach for a ~/custom/ directive, derived mostly from Renzo's answer.

(defun custom (stream number &rest args)
  (declare (ignore args))
  (multiple-value-bind (integer decimal) (truncate number)
    (format stream "~,,'.,:D~@[,~a~]"
            integer
            (unless (zerop decimal)
              (let ((decimal-string (princ-to-string (abs decimal))))
                (subseq decimal-string (1+ (position #\. decimal-string))))))))

TESTS

(loop for i in '(1034.34 -223.12 -10.0 10.0 14 324 1020231)
      collect (custom nil i))

=> ("1.034,33996582" "-223,11999512" "-10" "10" "14" "324" "1.020.231")
Emelun answered 26/1, 2016 at 15:50 Comment(5)
It's probably not even the intended behavior with some positive numbers. E.g., (round 1024.56) returns, as multiple values, 1025 and -0.4399414.Tetra
@JoshuaTaylor Yes, indeedEmelun
That said, I started to take the same approach that you're using, and ran into the same behavior that you're producing: (format t "~F" 1034.34) produces 1034.34, so the proposed solution should probably produce 1.034,34, not 1.034,33996582, if we're going for a drop-in replacement.Tetra
@JoshuaTaylor I cannot manage to format the fractional part as you say, but I don't have much time to test right now. Please feel free to edit or comment if you have another way to print it.Emelun
I think that splitting the result from ~F and updating as needed is probably the way to go; I added an answer.Tetra
M
0

I've come to this little solution for positive numbers.

(defun comma-point (stream arg &rest args)
  (declare (ignore args))
  (multiple-value-bind (i r) (truncate arg)
    (format stream "~,,',,:D.~2,'0D" i (truncate (* 100 r)))))
;;                      ^   ^
;;                      |   `Decimal point
;;                      `Thousands separator

(defun point-comma (stream arg &rest args)
  (declare (ignore args))
  (multiple-value-bind (i r) (truncate arg)
    (format stream "~,,'.,:D,~2,'0D" i (truncate (* 100 r)))))

(defun space-comma (stream arg &rest args)
  (declare (ignore args))
  (multiple-value-bind (i r) (truncate arg)
    (format stream "~,,' ,:D,~2,'0D" i (truncate (* 100 r)))))

The testing numbers:

(dolist (value '(1034.34 -223.12 -10.0 10.0 14 324 1020231.099))
  (format t "~16@A" (format nil "~/comma-point/" value))
  (format t "~16@A" (format nil "~/point-comma/" value))
  (format t "~16@A~%" (format nil "~/space-comma/" value)))

;;        1,034.33        1.034,33       1 034,33
;;        -223.-11        -223,-11       -223,-11
;;          -10.00          -10,00         -10,00
;;           10.00           10,00          10,00
;;           14.00           14,00          14,00
;;          324.00          324,00         324,00
;;    1,020,231.12    1.020.231,12   1 020 231,12

The second test number shows that does not work for negative numbers (-223.11 => -223,-11). Also, using truncate (or other similar functions) implies that a loss of accuracy appears, as can be seen in the last test number (1020231.099 => 1.020.231,12).

Mita answered 18/8, 2021 at 14:3 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.