Guiding Design Principles¶
In this section we summarize some guiding principles for designing and organizing scientific Python code.
Collaborate¶
Software developed by several people is preferable to software developed by one. By adopting the conventions and tooling used by many other scientific software projects, you are well on your way to making it easy for others to contribute. Familiarity works in both directions: it will be easier for others to understand and contribute to your project, and it will be easier for you to use other popular open-source scientific software projects and modify them to your purposes.
Talking through a design and the assumptions in it helps to clarify your thinking.
Collaboration takes trust. It is OK to be “wrong”; it is part of the process of making things better.
Having more than one person understanding every part of the code prevents systematic risks for the project and keeps you from being tied to that code.
If you can bring together contributors with diverse scientific backgrounds, it becomes easier to identify functionality that should be generalized for reuse by different fields.
Don’t Be Afraid to Refactor¶
No code is ever right the first (or second) time.
Refactoring the code once you understand the problem and the design trade-offs more fully helps keep the code maintainable. Version control, tests, and linting are your safety net, empowering you to make changes with confidence.
Prefer “Wide” over “Deep”¶
It should be possible to reuse pieces of software in a way not anticipated by the original author. That is, branching out from the initial use case should enable unplanned functionality without a massive increase in complexity.
When building new things, work your way down to the lowest level, understand that level, and then build back up. Try to imagine what else you would want to do with the capability you are implementing for other research groups, for related scientific applications, and next year.
Take the time to understand how things need to work at the bottom. It is better to slowly deploy a robust extensible solution than to quickly deploy a brittle narrow solution.
Keep I/O Separate¶
One of the biggest impediments to reuse of scientific code is when I/O code—assuming certain file locations, names, formats, or layouts—is interspersed with scientific logic.
I/O-related functions should only perform I/O. For example, they should take in a filepath and return a numpy array, or a dictionary of arrays and metadata. The valuable scientific logic should be encoded in functions that take in standard data types and return standard data types. This makes them easier to test, maintain when data formats change, or reuse for unforeseen applications.
Duck Typing is a Good Idea¶
Duck typing treats objects based on what they can do, not based on what type they are. “If it walks like a duck and it quacks like a duck, then it must be a duck.”
Python in general and scientific Python in particular leverage interfaces to
support interoperability and reuse. For example, it is possible to pass a
pandas DataFrame to the numpy.sum()
function even though pandas was
created long after numpy.sum()
. This is because numpy.sum()
avoids
assuming it will be passed specific data types; it accepts any object that
provides the right methods (interfaces). Where possible, avoid isinstance
checks in your code, and try to make your functions work on the broadest
possible range of input types.
“Stop Writing Classes”¶
Not everything needs to be object-oriented. Object-oriented design frequently does not add value in scientific computing.
It is better to have 100 functions operate on one data structure than 10 functions on 10 data structures.
—From ACM’s SIGPLAN publication, (September, 1982), Article “Epigrams in Programming”, by Alan J. Perlis of Yale University.
It is often tempting to invent special objects for a use case or workflow —
an Image
object or a DiffractionAnalysis
object. This approach has
proven again and again to be difficult to extend and maintain. It is better to
prefer standard, simple data structures like Python dictionaries and numpy
arrays and use simple functions to operate on them.
A popular talk, “Stop Writing Classes,” which you can watch on YouTube, illustrates how some situations that seem to lend themselves to object-oriented programming are much more simply handled using plain, built-in data structures and functions.
As another example, the widely-used scikit-image library initially experimented
with using an Image
class, but ultimately decided that it was better to use
plain old numpy arrays. All scientific Python libraries understand numpy
arrays, but they don’t understand custom classes, so it is better to pass
application-specific metadata alongside a standard array than to try to
encapsulate all of that information in a new, bespoke object.
Permissiveness Isn’t Always Convenient¶
Overly permissive code can lead to very confusing bugs. If you need a flexible user-facing interface that tries to “do the right thing” by guessing what the users wants, separate it into two layers: a thin “friendly” layer on top of a “cranky” layer that takes in only exactly what it needs and does the actual work. The cranky layer should be easy to test; it should be constrained about what it accepts and what it returns. This layered design makes it possible to write many friendly layers with different opinions and different defaults.
When it doubt, make function arguments required. Optional arguments are harder to discover and can hide important choices that the user should know that they are making.
Exceptions should just be raised: don’t catch them and print. Exceptions are a tool for being clear about what the code needs and letting the caller decide what to do about it. Application code (e.g. GUIs) should catch and handle errors to avoid crashing, but library code should generally raise errors unless it is sure how the user or the caller wants to handle them.
Write Useful Error Messages¶
Be specific. Include what the wrong value was, what was wrong with it, and perhaps how it might be fixed. For example, if the code fails to locate a file it needs, it should say what it was looking for and where it looked.
Write for Readability¶
Unless you are writing a script that you plan to delete tomorrow or next week, your code will probably be read many more times than it is written. And today’s “temporary solution” often becomes tomorrow’s critical code. Therefore, optimize for clarity over brevity, using descriptive and consistent names.
Complexity is Always Conserved¶
Complexity is always conserved and is strictly greater than the system the code is modeling. Attempts to hide complexity from the user frequently backfire.
For example, it is often tempting to hide certain reused keywords in a function, shortening this:
def get_image(filename, normalize=True, beginning=0, end=None):
...
into this:
def get_image(filename, options={}):
...
Although the interface appears to have been simplified through hidden keyword
arguments, now the user needs to remember what the options
are or dig
through documentation to better understand how to use them.
Because new science occurs when old ideas are reapplied or extended in unforeseen ways, scientific code should not bury its complexity or overly optimize for a specific use case. It should expose what complexity there is straightforwardly.
Note
Even better, you should consider using “keyword-only” arguments, introduced in Python 3, which require the user to pass an argument by keyword rather than position.
get_image(filename, *, normalize=True, beginning=0, end=None):
...
Every argument after the *
is keyword-only. Therefore, the usage
get_image('thing.png', False)
will not be allowed; the caller must
explicitly type get_image('thing.png', normalize=False)
. The latter is
easier to read, and it enables the author to insert additional parameters
without breaking backward compatibility.
Similarly, it can be tempting to write one function that performs multiple steps and has many options instead of multiple functions that do a single step and have few options. The advantages of “many small functions” reveal themselves in time:
Small functions are easier to explain and document because their behavior is well-scoped.
Small functions can be tested individually, and it is easy to see which paths have and have not yet been tested.
It is easier to compose a function with other functions and reuse it in an unanticipated way if its behavior is well-defined and tightly scoped. This is the UNIX philosophy: “Do one thing and do it well.”
The number of possible interactions between arguments goes up with the number of arguments, which makes the function difficult to reason about and test. In particular, arguments whose meaning depends on other arguments should be avoided.
Functions should return the same kind of thing no matter what their arguments, particularly their optional arguments. Violating “return type stability” puts a burden on the function’s caller, which now must understand the internal details of the function to know what type to expect for any given input. That makes the function harder to document, test, and use. Python does not enforce return type stability, but we should try for it anyway. If you have a function that returns different types of things depending on its inputs, that is a sign that it should be refactored into multiple functions.
Python is incredibly flexible. It accommodates many possible design choices. By exercising some restraint and consistency with the scientific Python ecosystem, Python can be used to build scientific tools that last and grow well over time.