Should `None` defaults for optional arguments be discouraged?

As a sidenote, I don’t understand why math.perm’s second argument is optional.

1 Like

For context, there’s some discussion at One argument form of math.perm() · Issue #81359 · python/cpython · GitHub

1 Like

Contrary to most reactions, I find it natural and desirable to accept None as an alternative to omit an optional parameter in a wide variety of APIs. Not because I like to read or write log(2.3, None) but because I like to be able to define a simple wrapper, e.g.

def my_log(x, base=None):
    # <extra stuff here>
    return math.log(x, base)

This allows both my_log(x) and my_log(x, base) to be called, and my_log() doesn’t have to hard-code knowledge about the default base. While it is possible to write a wrapper that accepts an optional extra argument and passes that on only when present, it is uglier and harder to read, e.g.

def my_log(x, *args):
    assert len(args) in (0, 1))
    # <extra stuff here>
    return math.log(x, *args)

This is slower too (even if you take out the assert) because handling *args takes a slower path in the interpreter. And why should you have to do it this way?

(EDIT: The version with *args also makes it more awkward to access base in the wrapper in case the “extra stuff” wants to intercept a certain base.)

20 Likes

I agree with Guido here. There is nothing wrong with accepting None as a proxy for “the default value” on arguments where there otherwise would never be a meaningful interpretation of None.

There is very minor consequence for static analysis in that it cannot infer that the value of a variable being passed into such an API must not be None after the call as the type signature of the function must naturally be declared as float|None. But this really doesn’t matter in practice.

Even when writing pure Python code, it may seem easier to write a function with its default value in the parameter list at first… But when it is something commonly wrapped or overridden, it can be worth the extra hoop to use None as the default and add an x = "default" if x is None in the method. Just to make the lives of other wrappers or overriders easier to avoid redeclared default copying or wacky conditional hoops.

6 Likes

One thing we do need (which I believe has been noted above) is a way to get the type signature showing up in the docstring (and thus IDE help text as Raymond noted in the issue) to display the underlying default.

That None is accepted is more of an implementation detail, not the way you want to document the API.

3 Likes

This does not work for a dict.pop() and range(). It would be nice to have either a special syntax for optional parameters without default value, e.g:

def mapping_pop(mapping, key, default=?):
    try:
        res = mapping[key]
    except KeyError:
        if not isset default:
            raise
        res = default
    else:
        del mapping[key]
    return res

or a way to overload function by the number of arguments, e.g:

def mapping_pop(mapping, key):
    res = mapping[key]
    del mapping[key]
    return res

@overload
def mapping_pop(mapping, key, default):
    try:
        res = mapping[key]
    except KeyError:
        res = default
    else:
        del mapping[key]
    return res

The former option may be more convenient in many simpler cases, but the latter option is more powerful.

2 Likes

It was declared in this way before and this is wrong: it makes an impression, that this is an ordinary case for a generic base (“calculated as `log(x)/log(base)” (c) rst docs).

But this is not the case: math.e is a float number, not a real number e. Same case we have, for example, in the exp() function, and that is noted in the rst docs: “Return e raised to the power x, where e = 2.718281… is the base of natural logarithms. This is usually more accurate than math.e ** x or pow(math.e, x).”. (BTW, for same reason some other docstrings/rst docs looks wrong, e.g. the exp: “Return e raised to the power of x.” Better: “Return the exponential of x.”)

The None seems to fit this case well just as for the perm: in the later function the default value depends on the first argument. In the math.log case - we can’t represent the default value by some other standard type (that might be possible with a come CAS, like the sagemath, but not with the current stdlib).

2 Likes

How about this fix for the math.log docstring (and for cmath.log):
Return the logarithm of x to the given base or the natural logarithm of x
(with the base=None patch, of course)?

That’s how look IDLE with this:

BTW @mdickinson, here is a new pr, that address some minor issues like above.

Unfortunately, such syntax (like c++ function overloading) isn’t possible with the CPython interpreter. I.e. I can’t reproduce pure-Python function with the current signature of the math.log, right?

I sympathize with Raymond’s objection, but also Mark’s unease.

For round, the absence of the 2nd argument is not the same as the presence of the default. A signature with ndigits=0 would be misleading.

>>> round(3.33)
3
>>> round(3.33, 0)
3.0

The summary line does not explain this difference, but the rest of the docstring does.

For math.log, the difference between no base and base=math.e is more theoretical than practical as long as log(math.e) == 1.0. But the unease is that (I think) it does not have to be, and aside from the time waste, we would not want an implementation of log to actually divide by log(e).

1 Like

See also this thread:

1 Like

I’m not seeing the damage here

In my reading of the issue, the “damage” is the expansion of the type of the base argument from “always a float” to “a float or maybe not-a-float,” which seems like a more “zoomed-in” concern than the question this thread is asking.

For whatever a user’s opinion matters here, I don’t see any reason that the stdlib should in general discourage the use of None to indicate an optional argument, but there may be individual cases where it muddies the waters.

This can work both ways, though, as even if something is syntactically a float, int, etc (like using 0, -1, or even math.e for a flag value, as some has proposed), semantically the meaning and behavior is a special case, different from that with any other int/float. Using None (or a dedicated sentinel type) ensures the syntax clearly communicates that this is a semantic special case, and can allows type checkers and other tools to statically verify it is being handled inside the function (or wrappers) and better introspect usage outside of it.

1 Like

Well said!

None is often a perfectly fine default.

But sometimes a function is more aesthetically pleasing if the default is not None. Then we get to argue about subjective judgements of beauty in code, which is always fun :wink:

And Raymond is right that we should make AC and Signature objects smarter and better able to deal with alternative APIs, and so reduce the pressure that every API must use defaults.

1 Like

Seems an ok compromise here.

VB/COM had a sentinel for “missing” argument. But it sounds an overkill to introduce such a sentinel to Python given the ecosystem has grown well without it.

The problem is how to not document a default value in the documentation, especially in introspection APIs like inspect.signature(). I don’t think that accepting a surprising but valid default value like -1 or None is a big deal: as Guido explained, it’s just convenient and practical.

When APIs were documented manually, we used syntax like math.log(value[, base]) for functions accepting 1 or 2 arguments. I never liked this syntax, it’s barely readable with many arguments, but so far, no one came with a better syntax :slight_smile:

Right now, the inspect.signature() API has no way to express “the default value should not be documented”. See also PEP 661 Sentinel Values: many functions use sentinel values which should not be documented.

2 Likes

Personally I’ve never been a fan of functions with mystery internal-only defaults. I see this as an anachronism when Python function declaration syntax wasn’t as rich, and a lot of functions were written in C with handmade argument parsing or PyArg_ParseTuple argument parsing, and maybe from before signature introspection. If I could wave my magic wand and change the world, I’d change Python so that optional arguments always had default values visible in the function’s signature.

Occasionally I stub my toe on this. For example, I might want to call a function both with an explicit value or with the default value of a parameter, but because the function has this mystery default value, I have to work a little harder–conditionally passing in the argument by splatting it in using *args or **kwargs, or maybe just writing two function calls with an if`. I admit I haven’t run into this recently enough for me to remember an example.

2 Likes

PEP 671 works in the same kind of field, trying to make defaults more visible even when they aren’t simple values. So I definitely agree with the principle (although with C-implemented functions, there’s probably no single approach that would work for all of them).

The issue is, this breaks for builtins. It seems pretty awkward trying to explain, e.g. the range constructor in these terms. What’s clearer:

range.__init__(start, stop, step=1) separately allows you to call it with only one argument, as range.__init__(stop), in which case start defaults to 0 and step to 1

or

range.__init__(start, stop=NOT_PROVIDED, step=1) will check if stop is NOT_PROVIDED and, in this case, reassign the provided start value to stop and assign 0 for start

? Granted, the latter is still clearer than showing the defaults as None. But it’s still quite awkward, and then it turns out that there isn’t an actual sentinel NOT_PROVIDED value you could use explicitly (even if you had access to it), and the internals actually take more of a *args-equivalent approach anyway, and in fact you can’t pass arguments by keyword at all…

Granted, if we had a time machine we could make it so that the built-ins only ever have Python-styled function signatures. (Maybe we could incorporate PEP 570 from the start while we’re at it.) But then it seems like you’re either stuck with the NOT_PROVIDED hack and the weird explanation in the doc (and then, do you actually want people to be able to write it with the sentinel explicitly?) Or maybe you don’t get a 1-arg form at all. Or you document the start/stop parameters as *endpoints and have to explain that actually it can only be either 1 or 2 elements etc.

On the other hand, being able to write range(10, step=2) does seem kinda neat…

I think this reflects more on range than on my position. As you point out, the signature of range can’t be easily expressed with a Python signature–and yet it’s a Python function. I regard the behavior of range as a historical artifact, the result of an early experiment in alternative approaches to argument processing from the early prehistory of Python. I assert that if we added range to the language today its signature would look very different.

Of course, I don’t have my magic wand, and I can’t retroactively change things, so my opinion there isn’t really actionable. We need to live with the situation as it exists. In order to non-awkwardly express the signature of range in Python, either we’d need to change the signature of range, or change the definiton of a Python function signature so it encompasses the existing behavior of range. Since the former is a non-starter, our only remaining choices are “enhance Python function signatures to encompass range” or “live with it being awkward”.

1 Like