Function IO Predictability

Can increasing the predictability of a function’s input/output reduce bugs in code, for instance, enforcing typing using constructs like if not isinstance(foobar, str):?

Probably not, but it also depends on what you mean by “predictability”.

The whole point of type annotations and type checking is that we can determine, without ever actually running our code, if we are using the correct types. So if you are using Mypy in strict mode, for the most part you should not need to perform basic type validation checks on input, and certainly not on output.

You can however add input validation checks to support external users that might not be interested in using a type checker on their own code. Usually you would only need to do this at the public interface of your own code, but if you are using a type checker then you shouldn’t need to do it in your code internally.

Keep in mind that bugs don’t randomly happen. They are mistakes in your code. If you find that the output of your own code is unpredictable, and you are not specifically doing something nondeterministic (like reading from a network or generating random numbers), then either you have a bug in your code, or you have written code that is so complicated that you do not understand what it’s doing anymore.

So maybe you are thinking about adding these kinds of checks in order to catch bugs in your code. That’s a nice idea, but that’s also why we write tests. So if you are using a type checker, and you are writing good tests, no, you should not need to add additional validation on the outputs of your own functions, and you should only need to add input validation as is appropriate to inform external users when they have made a mistake.

What you might also be inventing for yourself is the idea of a “contract” in which every function has some set of “preconditions” and “postconditions” which are expected to be true before and after the function runs. There is an entire programming paradigm built around this idea, called “design by contract”, and there are a couple of frameworks for using it in Python, notably Deal and Crosshair. However, these contract assertions are not typically executed during normal usage of the code. They are only run as part of the testing process, essentially embedding directly into your program what you would otherwise want to encode in unit tests or integration tests.

Beyond that, I’m not sure what else you might mean by “predictability”. You also mentioned the term “IO” but seem to be only referring to the inputs and outputs of functions that you write, whereas normally “I/O” refers to communication between your program and the outside world, such as the filesystem, network, etc.

3 Likes

In general, sure, but it depends. :slightly_smiling_face: I think what software programmers then debate endlessly is exactly how to increase that “predictability”. :wink: (because we need our programs to work!)

@gwerbin beat me to mentioning design by contract (and summarized it much more succinctly!) but I’ve preserved my wandering monologue here in this details block:

Long-winded lecture on programming by contract and use of type systems as a subset of precondition verification

One approach is called “Programming by Contract.” In this, you specify a “contract” between a function and any function that calls it, by defining pre-conditions and post-conditions. A precondition is something that must be true for the function to work correctly. It might be that a value falls within a certain range, or that the file you pass to the function is writable, etc. It is anything that the function itself needs to work correctly. Post-conditions are things that will be true after the function runs, if all the preconditions were true. In other words, you define for each function:

“If (condition 1, condition 2, and condition 3) are true when this function is called, then after the function returns (condition 4, condition 5, …) will be true.”

When writing the function, inside the function you assume the preconditions are always true. You may check for common mistakes, or to verify assumptions you aren’t sure about, or to detect exactly what a problem is if you want to give a nice error message… but you don’t have to. Because if the preconditions were not met, the function isn’t bound by its contract anymore. Likewise, when writing a function that calls another, if you’re satisfied that the other function’s preconditions are met, you can implement it as though the post conditions will always be true - you don’t write extra code to verify that the function you called behaved correctly, because there’s a contract!

At the basic level this is just a way of thinking about and designing functions that makes sure you always think through how a function is supposed to behave, writing that down somewhere (because it makes documentation for you and people who use your function), and allows you to not have to write the same validation checks over and over.

There is also formal Design by Contract, where you always and explicitly do this process for everything in a whole system. And some languages have support for contracts built in - you can write the preconditions and post conditions in a notation the compiler or interpreter understands and it will try to verify them for you and tell you if you’ve made a mistake. That connects to a field called formal verification, which designs tools and methods to prove whether a program is correct or not. But that’s its own thing.

Different languages have different parts of this stuff built in. You can think of type systems as a subset of contracts. Statically typed and compiled languages (Java, C++, C#) add type annotations to functions and variables as a form of precondition, and when you try to compile the program the compiler checks all those preconditions for you. Rust is interesting because it adds another layer designed to check for memory errors- functions get annotated (usually automatically) with information about the memory their variables use and what part of the program controls it, and the compiler then checks to make sure you haven’t made a mistake that would lead to memory bugs. Some languages (Ada) let you make new types that are a range inside an existing type - for example, instead of a function taking an integer argument, you can specify that it must be an integer in the range 0 to 100 - and the compiler will verify that too.

Dynamically typed and interpreted languages are looser about type annotations and often don’t require them at all. This is a trade off between how “safe” the language is and how flexible it is to use, because generally you can create programs much more quickly the more the language allows. Instead of a compiler warning you about stuff, you have to verify a bit more yourself, using either runtime checks (like the one you described) or unit tests where you try to test all the ways the function could possibly execute to see if it works the way you expect. As a rule of thumb, in a statically typed language you write a few less unit tests (but still write them!) while in dynamically typed ones you write a few more, because there isn’t a compiler checking some of the cases for you.

The idea of all of the above is to reduce the number of things your function has to check at runtime:

  • Every additional check takes time - lots of checks in every function means slow programs.
  • You have to decide how a function responds for every check, and sometimes there just isn’t a reasonable thing to do (leading to a generic and not very useful Exception(“This should never happen but did”)
  • It makes no sense for a “child” function to recheck things it’s parent already checked

Ideally, the only things your function has to check before doing its “real work” are things you couldn’t possibly know ahead of time. This is where system IO / operating system errors live - you can’t know when you create your program whether that file you need will be there, you just have to check! It’s also common to add checks for mistakes that are easy to make, or for cases where you know you might make a change in the future and you want the function to fail if you forget to update it alongside the new change.

About your specific example - checking the type of a variable at runtime - in Python in particular, you usually want to avoid checks like this, or at least be very careful with how you write them. Python has a neat superpower called “duck typing”. When you pass a value to some function in Python, it doesn’t care what the type of the object is, as long as it has the attributes and methods the function needs. You can just try to access the property you expect the object to have - and as long as it has it, who cares why the type of the object was? It had the bits you needed. This is neat because it lets you write really general functions that expect a particular “shape” without having to specify all the classes and values that match that shape. It makes future extension very easy. So instead of checking whether an argument is a string, you just go ahead and use the attributes you expect a string to have and complain if they’re missing, because someone may have created a new type that acts like a string for all the purposes you actually need, but isn’t a subclass of str.

1 Like

Thank you for correcting me and providing additional information. To clarify, if I understand correctly, it’s acceptable to use preconditions even if I create a unit test, although it may add considerable boilerplate code. For example, I will establish a requirement that the sha256 parameter must comprise solely of alphanumeric characters and possess a length of 64 characters. However, I will no longer examine the object type as there may be a subtype.

isinstance checks for subtypes, that’s not a problem. You certainly can check types of inputs in a user-facing function, although usually I just use type annotations.

And yes, it’s fine to check things like string length at the start of a function. Again, it’s a matter of the intended use: if this is a user-facing function, checking for correctness is a good thing.

But if this is all just meant for internal use in some application, it’s maybe not worth the effort. Consider that every check in your function is essentially logic that ought to be tested. Do you want to write a test case that passes a float to the sha256 parameter and asserts that a TypeError is raised? Maybe if you’re writing a library for public use, maybe not for the internals of an application.

1 Like

This is not true. If you don’t type check on input, you can end up being surprised when any input functions that behaves like the Python 2 input() suddenly decided to cast your input to different types based on user input.

This might not be as obtuse as Python 2 input(), a function that takes date input for example, may be designed such that if user keeps entering invalid dates, they’d just return None to indicate permanent error. If you’re not validating those input, you’re going to have problems in your code.

This is why libraries like Pydantic exists, and it’s why IMO Pydantic is generally a much more useful use case for the type annotation syntax than a static checker like Mypy. Pydantic does runtime type checking and validation, and then it coerces your input value to declared type. The type inferences done by Mypy is really only useful after you have validated your input using a runtime type validators like Pydantic (or if you hand roll similar validation mechanism).

Only after you’ve already coerced your input to a well defined type, would it be possible to make useful inferences about types and program behaviour, whether those inferences are done using mypy or manually using brain and logic. Without input validation, static type checking really will only gives you false sense of correctness.

There are two different kinds of “input” being talked about here.

If you are processing external input at the boundaries of an application or web API, then you need to validate it and Pydantic is a great tool for that.

If you’re passing data between functions, or receiving input at the boundary of a library, you’re probably not talking about “untrusted input” anymore. Instead you’re declaring component interfaces for yourself or others to use from other code. Static analysis tools like type checkers are great tools for that.

These tools complement each other but are ultimately for two different things.

1 Like

Probably not, but it also depends on what you mean by “predictability”.

The whole point of type annotations and type checking is that we can determine, without ever actually running our code, if we are using the correct types. So if you are using Mypy in strict mode, for the most part you should not need to perform basic validation checks on input, and certainly not on output.

Provided you’ve got type coverage i.e. mypy either has access to type
annotations for everything or can infer what’s missing.

You can however add input validation checks to support external users that might not be interested in using a type checker on their own code. Usually you would only need to do this at the public interface of your own code, but if you are using a type checker then you shouldn’t need to do it in your code internally.

Some of the trickiness here is defining in your mind what the public
interface is. Unless I’ve given a function or method a special _*
“private” name, I broadly expect that it might be called by anything.

To me, type checking doesn’t just bring lint-style consistency checks
but also helps document the expected values and returns, which has value
for the programmer/user.

As an example, in a Django project I’m on we use UUIDs as keys an many
places. There’s a constant tension between getting a UUID object (for
example from an object’s primary key) and a str containing the string
form of a UUID (eg from a CSV file or some JSON etc etc). While Django
itself papers over this in filters:

 assets = Asset.objects.filter(uuid__in=some_list_of_uuids)

which accepts UUID objects or str, and a mix, if you’re constructing
a dict mapping UUIDs to something, you need to be consistent - always
str or always UUID.

Because of this, having a function be clear about what you’re getting
back is very useful to the programmer:

 def asset_keys(self, blah, blah, blah) -> List[UUID]:

I can expect to get UUIDs from this.

[…]

What you might also be inventing for yourself is the idea of a
“contract” in which every function has some set of “preconditions” and
“postconditions” which are expected to be true before and after the
function runs. There is an entire programming paradigm built around
this idea, called “design by
contract”
, and
there are a couple of frameworks for using it in Python, notably
Deal and
Crosshair.

I’d also add icontract, which I
use.

I imagine most have similar interfaces, using decorators on functions to
express the contract:

 from icontract import (
     require,    # preconditions which must be true at call time
     ensure,     # postconditions which must be true on return
 )

 @require(lambda size: size > 0)
 @ensure(lambda result, size: len(result) <= size)
 def read_from_file(f, size) -> bytes:
     '''
     A trite function because I couldn't be bothered making anything 
     complex for this example.
     '''
     return f.read(size)

These are also useful for aiding the programmer.

However, these contract assertions are not typically executed during
normal usage of the code. They are only run as part of the testing
process, essentially embedding directly into your program what you
would otherwise want to encode in unit tests or integration tests.

icontract runs unless you’re using -O (the same switch which
disables assertions).

I also use typeguard which lets
you do some runtime type checking, which is useful for code which
doesn’t have full type coverage or which doesn’t enforce a type check
lint pass before use.

 from typeguard import typechecked

 @typechecked
 def func(f, bs: bytes) -> int:

It also runs unless you’re using -O.

I tend to put these on “entry” style functions, like class object
initialisers, and other functions which are both infrequent (an object
init happens once per object versus calling its methods perhaps many
times) or on functions where it can be easy to misuse the arguments and
I want a better error. I do not annotate everything with
@typechecked - that’s what a static type checker like mypy is for.

To my mind contracts and unit tests are both valuable. It can be hard to
write a complete contract with something like icontract, particularly
if there are several corner cases, and a complex contract can obscure
the code. Unit tests can cover those things far better, particularly if
you address bugs by adding a unit test which fails because of the bug,
and making it pass.

Likewise type annotations: they’re great for code documentation in
addition to letting things like mypy do type checking of your
codebase. And it can be handy to have some runtime type checks.

Cheers,
Cameron Simpson cs@cskk.id.au

3 Likes