PEP: 682
Title: Format Specifier for Signed Zero
Author: John Belmonte <john@neggie.net>
Sponsor: Mark Dickinson <dickinsm@gmail.com>
PEP-Delegate: Mark Dickinson
Discussions-To: https://discuss.python.org/t/pep-682-format-specifier-for-signed-zero/13596
Status: Final
Type: Standards Track
Created: 29-Jan-2022
Python-Version: 3.11
Post-History: 08-Feb-2022
Resolution: https://discuss.python.org/t/accepting-pep-682-format-specifier-for-signed-zero/14088


Abstract
========

Though ``float`` and ``Decimal`` types can represent `signed zero`_, in many
fields of mathematics negative zero is surprising or unwanted -- especially
in the context of displaying an (often rounded) numerical result.  This PEP
proposes an extension to the `string format specification`_ allowing negative
zero to be normalized to positive zero.

.. _`signed zero`: https://en.wikipedia.org/wiki/Signed_zero
.. _`string format specification`: https://docs.python.org/3/library/string.html#formatstrings


Motivation
==========

Here is negative zero::

    >>> x = -0.
    >>> x
    -0.0

When formatting a number, negative zero can result from rounding.  Assuming
the user's intention is truly to discard precision, the distinction between
negative and positive zero of the rounded result might be considered an
unwanted artifact::

    >>> for x in (.002, -.001, .060):
    ...     print(f'{x: .1f}')
     0.0
    -0.0
     0.1

There are various approaches to clearing the sign of a negative zero.  It
can be achieved without a conditional by adding positive zero::

    >>> x = -0.
    >>> x + 0.
    0.0

To normalize negative zero when formatting, it is necessary to perform
a redundant (and error-prone) pre-rounding of the input::

    >>> for x in (.002, -.001, .060):
    ...     print(f'{round(x, 1) + 0.: .1f}')
     0.0
     0.0
     0.1

There is ample evidence that, regardless of the language, programmers are
often looking for a way to suppress negative zero, and landing on a
variety of workarounds (pre-round, post-regex, etc.).  A sampling:

* `How to have negative zero always formatted as positive zero in a
  python string?`_ (Python, post-regex)
* `(Iron)Python formatting issue with modulo operator & "negative zero"`_
  (Python, pre-round)
* `Negative sign in case of zero in java`_ (Java, post-regex)
* `Prevent small negative numbers printing as "-0"`_ (Objective-C, custom
  number formatter)

What we would like instead is a first-class option to normalize negative
zero, on top of everything else that numerical string formatting already
offers.

.. _`How to have negative zero always formatted as positive zero in a python string?`: https://stackoverflow.com/questions/11010683/how-to-have-negative-zero-always-formatted-as-positive-zero-in-a-python-string/36604981#36604981
.. _`(Iron)Python formatting issue with modulo operator & "negative zero"`: https://stackoverflow.com/questions/41564311/ironpython-formatting-issue-with-modulo-operator-negative-zero/41564834#41564834
.. _`Negative sign in case of zero in java`: https://stackoverflow.com/questions/11929096/negative-sign-in-case-of-zero-in-java
.. _`Prevent small negative numbers printing as "-0"`: https://stackoverflow.com/questions/10969399/prevent-small-negative-numbers-printing-as-0


Rationale
=========

There are use cases where negative zero is unwanted in formatted number
output -- arguably, not wanting it is more common.  Expanding the format
specification is the best way to support this because number formatting
already incorporates rounding, and the normalization of negative zero must
happen after rounding.

While it is possible to pre-round and normalize a number before formatting,
it's tedious and prone to error if the rounding doesn't precisely match
that of the format spec.  Furthermore, functions that wrap formatting would
find themselves having to parse format specs to extract the precision
information.  For example, consider how this utility for formatting
one-dimensional numerical arrays would be complicated by such pre-rounding:

.. code-block:: python

    def format_vector(v, format_spec='8.2f'):
        """Format a vector (any iterable) using given per-term format string."""
        return f"[{','.join(f'{term:{format_spec}}' for term in v)}]"

To date, there doesn't appear to be any other widely-used language or library
providing a formatting option for negative zero.  However, the same ``z``
option syntax and semantics specified below have been `proposed for C++
std::format()`_.  While the proposal was withdrawn for C++20, a consensus
proposal is promised for C++23.  (The original `feature request`_ prompting
this PEP was argued without knowledge of the C++ proposal.)

When Rust developers debated whether to suppress negative zero in ``print``
output, they took a small `survey of other languages`_.  Notably, it didn't
mention any language providing an option for negative zero handling.

.. _`proposed for C++ std::format()`: http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/p1496r2.pdf
.. _`feature request`: https://bugs.python.org/issue45995
.. _`survey of other languages`: https://github.com/rust-lang/rfcs/issues/1074#issuecomment-718243936


Specification
=============

An optional, literal ``z`` is added to the
`Format Specification Mini-Language`_ following ``sign``:

.. code-block:: text

    [[fill]align][sign][z][#][0][width][grouping_option][.precision][type]

where ``z`` is allowed for floating-point presentation types (``f``, ``g``,
etc.,  as defined by the format specification documentation).  Support for
``z`` is provided by the ``.__format__()`` method of each numeric type,
allowing the specifier to be used in f-strings, built-in ``format()``, and
``str.format()``.

When ``z`` is present, negative zero (whether the original value or the
result of rounding) will be normalized to positive zero.

Synopsis::

    >>> x = -.00001
    >>> f'{x:z.1f}'
    '0.0'

    >>> x = decimal.Decimal('-.00001')
    >>> '{:+z.1f}'.format(x)
    '+0.0'

.. _`Format Specification Mini-Language`: https://docs.python.org/3/library/string.html#format-specification-mini-language


Design Notes
------------
The solution must be opt-in, because we can't change the behavior of
programs that may be expecting or relying on negative zero when formatting
numbers.

The proposed extension is intentionally ``[sign][z]`` rather than
``[sign[z]]``.  The default for ``sign`` (``-``) is not widely known or
explicitly written, so this avoids everyone having to learn it just to use
the ``z`` option.

While f-strings, built-in ``format()``, and ``str.format()`` can access
the new option, %-formatting cannot.  There is already precedent for not
extending %-formatting with new options, as was the case for the
``,`` option (:pep:`378`).

C99 ``printf`` already uses the ``z`` option character for another
purpose:  qualifying the unsigned type (``u``) to match the length of
``size_t``.  However, since the signed zero option specifically disallows
``z`` for integer presentation types, it's possible to disambiguate the two
uses, should C want to adopt this new option.


Backwards Compatibility
=======================

The new formatting behavior is opt-in, so numerical formatting of existing
programs will not be affected.


How to Teach This
=================
A typical introductory Python course will not cover string formatting
in full detail.  For such a course, no adjustments would need to be made.
For a course that does go into details of the string format specification,
a single example demonstrating the effect of the ``z`` option on a negative
value that's rounded to zero by the formatting should be enough.  For an
independent developer encountering the feature in someone else's code,
reference to the `Format Specification Mini-Language`_ section of the
library reference manual should suffice.

.. _`Format Specification Mini-Language`: https://docs.python.org/3/library/string.html#format-specification-mini-language


Reference Implementation
========================

A reference implementation exists at `pull request #30049`_.

.. _`pull request #30049`: https://github.com/python/cpython/pull/30049


Copyright
=========

This document is placed in the public domain or under the
CC0-1.0-Universal license, whichever is more permissive.
