Designing functions with optional parameters
When we define a function, we often have a need for optional parameters. This allows us to write functions that are more flexible and easier to read.
We can also think of this as a way to create a family of closely-related functions. We can think of each function as having a slightly different collection of parameters – called the signature – but all sharing the same simple name. This is sometimes called an "overloaded" function. Within the typing module, an @overload decorator can help create type hints in very complex cases.
An example of an optional parameter is the built-in int() function. This function has two signatures:
- int(str): For example, the value of int('355') has a value of 355. In this case, we did not provide a value for the optional base parameter; the default value of 10 was used.
- int(str, base): For example, the value of int('163', 16) is 355. In this case, we provided a value for the base parameter.
Getting ready
A great many games rely on collections of dice. The casino game of Craps uses two dice. A game like Zonk (or Greed or Ten Thousand) uses six dice. Variations of the game may use more.
It's handy to have a dice-rolling function that can handle all of these variations. How can we write a dice simulator that works for any number of dice, but will use two as a handy default value?
How to do it...
We have two approaches to designing a function with optional parameters:
- General to Particular: We start by designing the most general solution and provide handy defaults for the most common case.
- Particular to General: We start by designing several related functions. We then merge them into one general function that covers all of the cases, singling out one of the original functions to be the default behavior. We'll look at this first, because it's often easier to start with a number of concrete examples.
Particular to General design
When following the particular to general strategy, we'll design several inpidual functions and look for common features. Throughout this example, we'll use slightly different names as the function evolves. This simplifies unit testing the different versions and comparing them:
- Write one game function. We'll start with the Craps game because it seems to be the simplest:
import random def die() -> int: return random.randint(1, 6) def craps() -> Tuple[int, int]: return (die(), die())
We defined a function, die(), to encapsulate a basic fact about standard dice. There are five platonic solids that can be pressed into service, yielding four-sided, six-sided, eight-sided, twelve-sided, and twenty-sided dice. The six-sided die has a long history, starting as Astragali bones, which were easily trimmed into a six-sided cube.
- Write the next game function. We'll move on to the Zonk game because it's a little more complex:
def zonk() -> Tuple[int, ...]: return tuple(die() for x in range(6))
We've used a generator expression to create a tuple object with six dice. We'll look at generator expressions in depth online in Chapter 9, Functional Programming Features (link provided in the Preface).
The generator expression in the body of the zonk() function has a variable, x, which is required syntax, but the value is ignored. It's also common to see this written as tuple(die() for _ in range(6)). The variable _ is a valid Python variable name; this name can be used as a hint that we don't ever want to use the value of this variable.
Here's an example of using the zonk() function:
>>> zonk() (5, 3, 2, 4, 1, 1)
This shows us a roll of six inpidual dice. There's a short straight (1-5), as well as a pair of ones. In some versions of the game, this is a good scoring hand.
Locate the common features in the craps() and zonk() functions. This may require some refactoring of the various functions to locate a common design. In many cases, we'll wind up introducing additional variables to replace constants or other assumptions.
In this case, we can refactor the design of craps() to follow the pattern set by zonk(). Rather than building exactly two evaluations of the die() function, we can introduce a generator expression based on range(2) that will evaluate the die() function twice:
def craps_v2() -> Tuple[int, ...]: return tuple(die() for x in range(2))
Merge the two functions. This will often involve exposing a variable that had previously been a literal or other hardwired assumption:
def dice_v2(n: int) -> Tuple[int, ...]: return tuple(die() for x in range(n))
This provides a general function that covers the needs of both Craps and Zonk.
- Identify the most common use case and make this the default value for any parameters that were introduced. If our most common simulation was Craps, we might do this:
def dice_v3(n: int = 2) -> Tuple[int, ...]: return tuple(die() for x in range(n))
Now, we can simply use dice_v3() for Craps. We'll need to use dice_v3(6) for Zonk.
- Check the type hints to be sure they describe the parameters and the return values. In this case, we have one parameter with an integer value, and the return is a tuple of integers, described by Tuple[int, ...].
Throughout this example, the name evolved from dice to dice_v2 and then to dice_v3. This makes it easier to see the differences here in the recipe. Once a final version is written, it makes sense to delete the others and rename the final versions of these functions to dice(), craps(), and zonk(). The story of their evolution may make an interesting blog post, but it doesn't need to be preserved in the code.
General to Particular design
When following the general to particular strategy, we'll identify all of the needs first. It can be difficult to foresee all the alternatives, so this may be difficult in practice. We'll often do this by introducing variables to the requirements:
- Summarize the requirements for dice-rolling. We might start with a list like this:
- Craps: Two dice
- First roll in Zonk: Six dice
- Subsequent rolls in Zonk: One to six dice
This list of requirements shows a common theme of rolling n dice.
- Rewrite the requirements with an explicit parameter in place of any literal value. We'll replace all of our numbers with a parameter, n, and show the values for this new parameter that we've introduced:
- Craps: n dice, where n = 2
- First roll in Zonk: n dice, where n = 6
- Subsequent rolls in Zonk: n dice, where 1 ≤ n ≤ 6
The goal here is to be absolutely sure that all of the variations really have a common abstraction. We also want to be sure we've properly parameterized each of the various functions.
- Write the function that fits the General pattern:
def dice(n): return tuple(die() for x in range(n))
In the third case – subsequent rolls in Zonk – we identified a constraint of 1 ≤ n ≤ 6. We need to determine if this is a constraint that's part of our dice() function, or if this constraint is imposed on the dice by the simulation application that uses the dice function. In this example, the upper bound of six is part of the application program to play Zonk; this not part of the general dice() function.
- Provide a default value for the most common use case. If our most common simulation was Craps, we might do this:
def dice(n=2): return tuple(die() for x in range(n))
- Add type hints. These will describe the parameters and the return values. In this case, we have one parameter with an integer value, and the return is a tuple of integers, described by Tuple[int, …]:
def dice(n: int=2) -> Tuple[int, ...]: return tuple(die() for x in range(n))
Now, we can simply use dice() for Craps. We'll need to use dice(6) for Zonk.
In this recipe, the name didn't need to evolve through multiple versions. This version looks precisely like dice_v2() from the previous recipe. This isn't an accident – the two design strategies often converge on a common solution.
How it works...
Python's rules for providing parameter values are very flexible. There are several ways to ensure that each parameter is given an argument value when the function is evaluated. We can think of the process like this:
- Set each parameter to its default value. Not all parameters have defaults, so some parameters will be left undefined.
- For arguments without names – for example, dice(2) – the argument values are assigned to the parameters by position.
- For arguments with names – for example, dice(n: int = 2) – the argument values are assigned to parameters by name. It's an error to assign a parameter both by position and by name.
- If any parameter still doesn't have a value, this raises a TypeError exception.
These rules allow us to create functions that use default values to make some parameters optional. The rules also allow us to mix positional values with named values.
The use of optional parameters stems from two considerations:
- Can we parameterize the processing?
- What's the most common argument value for that parameter?
Introducing parameters into a process definition can be challenging. In some cases, it helps to have concrete example code so that we can replace literal values (such as 2 or 6) with a parameter.
In some cases, however, the literal value doesn't need to be replaced with a parameter. It can be left as a literal value. Our die() function, for example, has a literal value of 6 because we're only interested in standard, cubic dice. This isn't a parameter because we don't see a need to make a more general kind of die. For some popular role-playing games, it may be necessary to parameterize the number of faces on the die to support monsters and wizards.
There's more...
If we want to be very thorough, we can write functions that are specialized versions of our more generalized function. These functions can simplify an application:
def craps():
return dice(2)
def zonk():
return dice(6)
Our application features – craps() and zonk() – depend on a general function, dice(). This, in turn, depends on another function, die(). We'll revisit this idea in the Picking an order for parameters based on partial functions recipe.
Each layer in this stack of dependencies introduces a handy abstraction that saves us from having to understand too many details in the lower layers. This idea of layered abstractions is sometimes called chunking. It's a way of managing complexity by isolating the details.
In this example, our stack of functions only has two layers. In a more complex application, we may have to introduce parameters at many layers in a hierarchy.
See also
- We'll extend on some of these ideas in the Picking an order for parameters based on partial functions recipe, later in this chapter.
- We've made use of optional parameters that involve immutable objects. In this recipe, we focused on numbers. In Chapter 4, Built-In Data Structures Part 1: Lists and Sets, we'll look at mutable objects, which have an internal state that can be changed. In the Avoiding mutable default values for function parameters recipe, we'll look at some additional considerations that are important for designing functions that have optional values, which are mutable objects.