omitted

A Do-Not-Care Value for Python


License
MIT
Install
pip install omitted==0.1.3

Documentation

omitted

A Do-Not-Care Value for Python

Let's Talk About Kwargs

It's easy to take for granted, but one of the most elegant aspects of Python is its handling of functional keyword arguments (kwargs). This one construct provides:

  1. A way to specify parameters without having to know its position in the argument list
  2. Automatic checking of expected parameters
  3. An elegant way to specify parameter defaults
  4. A meaningful, self-documenting function signature

Most of the time kwargs work flawlessly; but there are a few cases where their use becomes tricky.

I'm going to talk about that problem, show one common anti-pattern that attempts to fix it, and then propose an alternate approach that retains all of the benefits mentioned above at the cost of introducing a new concept to Python, the Omitted singleton.

Problem, What Problem?

Let's start off with an example. Suppose you have a function which takes a person and allows you to update the person's name, age, both, or neither. Naively, you might write that function as something like:

def update(person, name=None, age=None):
    person.name = name
    person.age = age

Now let's call this function, but only update the person's age:

> person = Person(name='Alice', age=32)
> update(person, age=33)
> person.age
33
> person.name
None

Notice that the code correctly set the age but, unexpectedly from the caller's point-of-view, also set the name to None. Not what we intended.

The problem here is that None can't be used to both represent a valid value and at the same time we used to represent a do-not-care condition.

Realizing we need a way to differentiate these two conditions, we modify the code slightly:

def update(person, **kwargs):
    for k, v in kwargs.items():
        setattr(person, k, v)

Here we're using the presence or absence of the key in the kwargs dict to represent the do-not-care condition and the value of None is solely reserved for actually setting the attribute to None (pretty reasonable, huh?).

Let's call this new version of update again:

> person = Person(name='Alice', age=32)
> update(person, age=33)
> person.age
33
> person.name
'Alice'

The method behaved exactly as we expected. And from my experience, many developers stop here because It Just Works. But it comes at a cost: we no longer have expected kwarg checking (benefit #2), no elegant way of setting default parameters (benefit #3), and we've lost our meaningful function signature (benefit #4). For these reasons, I consider this an anti-pattern and propose a different approach.

None More None

To retain the benefits of explicit kwargs, we need a way for the value of the keyword to differentiate between the do-not-care condition and a valid value. As we've seen above, None isn't good enough since None is a perfectly reasonable value for many attributes.

Instead we need to create a new value, a singleton like None that can represent this case which I call Omitted. With it, our function becomes:

Omitted = object()

def update(person, name=Omitted, age=Omitted):
    if name is not Omitted:
        person.name = name

    if age is not Omitted:
        person.age = age

We end up with code that behaves exactly as we'd expect when called, but also preserves the benefits of explicit kwargs:

update(person, age=33)      # Just set the age to 33
update(person, name=None)   # Set the name to None
update(person)              # Doesn't update any attributes
update(person, foo=bar)     # Raises a TypeError because foo isn't an
                            # expected kwargs (benefit #2)

So What's The Catch?

So, solving our default kwarg problem, we've gone ahead and published our library containing our update function. Now suppose that someone comes along and wants to use our library. First of all, they'll be appreciative of our usage of explicit kwargs--once they get past the unusual looking Omitted defaults.

However, when they go to use it, they might do something like:

Omitted = object()

def update_with_email(person, name=Omitted, age=Omitted):
    update(person, name=name, age=age)
    send_email(person)

> person = Person(name='Alice', age=32)
> update_with_email(age=33)
> person.age
33
> person.name
<object object at 0x105ae2080>

As you can see, person.name has ended up with the value of Omitted. The problem here is that caller's Omitted instance is different from the libraries Omitted instance, so they don't compare as identical.

What we'd like is a way to define a single global singleton that represents this do-not-care condition across all Python packages, in the same way that None is identical no matter where it's used.

Introducing...

This Python module aims to solve this problem by defining the one-and-only Omitted instance, shareable between all packages on the system.

To be clear, just because a library uses Omitted, it doesn't mean the calling code must as well. Not passing the kwarg or setting it to None will behave correctly without having to know that Omitted was used behind the scenes to make it work.

The only time a caller would need to import Omitted is if they wanted to proxy the do-not-care condition from the caller into the library. In that case, you'd just do something like:

from libperson import update
from omitted import Omitted

def update_with_email(person, name=Omitted, age=Omitted):
    update(person, name=name, age=age)
    send_email(person)

With that in mind, go ahead, import Omitted and let your code stop caring.