4 ms·
Pushing "ifs" up has the downside that the preconditions and postconditions are no longer directly visible in the definition of a function, and must then be che
by nerdponx 3y ago
Pushing "ifs" up has the downside that the preconditions and postconditions are no longer directly visible in the definition of a function, and must then be checked at each call site. In bigger projects with multiple contributors, such functions could end up getting reused outside their intended context. The result is bugs.
One solution is some kind of contract framework, but then you end up rewriting the conditions twice, once in the contract and once in the code. The same is true with dependent types.
One idea I haven't seen before is the idea of tagging regions of code as being part of some particular context, and defining functions that can only be called from that context.
Hypothetically in Python you could write:
@requires_context("VALIDATED_XY")
def do_something(x, y):
...
@contextmanager
def validated_xy(x, y):
if abs(x) < 1 and abs(y) < 1:
with context("VALIDATED_XY"):
yield x, y
else:
raise ValueError("out of bounds")
with validated_xy(0.5, 0.5) as x_safe, y_safe:
do_something(x_safe, y_safe)
# Error!
do_something(0.5, 0.5)
The language runtime has no knowledge of what the context actually means, but with appropriate tools (and testing), we could design our programs to only establish the desired context when a certain condition is met.
You could enforce this at the type level in a language like Haskell using something like the identity monad.
But even if it's not enforced at the type level, it could be an interesting way to protect "unsafe" regions of code.
- timeon 3y ago> In bigger projects with multiple contributors, such functions could end up getting reused outside their intended context. The result is bugs. Yes but in this particular example `fn frobnicate(walrus: Walrus)` if you pass here anything other then owned Walrus then program would not compile. Even if it was something generic passing the arg would have to satisfy trait bounds. Definition of those bounds in function definition would be required by compiler based on how the argument will be used inside function.
- imron 3y ago> Pushing "ifs" up has the downside that the preconditions and postconditions are no longer directly visible in the definition of a function You're missing the second part of the author's argument: "or it could push the task of precondition checking to its caller, and enforce via types" The precondition is therefore still directly visible in the function definition - just as part of the type signature rather than in an if statement. The "enforce preconditions via types" is a common pattern in Rust (the language used in the article), and unlike checking with if statements, it's a strict precondition that is checked at compile time rather than at runtime and you won't even be able to compile your program if you don't meet the pre-condition.
- nerdponx 3y agoDefinitely, that's a pattern in many programming languages (including, increasingly, in Python). And in dependently-typed languages, you actually can obtain a proof of validity that gets erased at runtime, leaving nothing but basic unboxed data to be executed. However that's not always an option in all languages or situations, and it's hard to encode conditions like "must only be called while an event loop is running" that way. I'm envisioning this partly as kind of a catch-all/fallback mechanism that can either be bolted on a language that doesn't have these kinds of features. But it's not always the case that you can effectively communicate contextual requirements through the types of function parameters, and this would cover that case as well. If you want to reduce this to anything, it's a workaround for not having monads in your language, with some tradeoffs around composability and dynamic extent.
- scns 3y ago> The "enforce preconditions via types" is a common pattern in Rust Called the Type State Pattern. Here are two links from my bookmarks: https://cliffle.com/blog/rust-typestate/ https://cliffle.com/blog/rust-typestate/ https://yoric.github.io/post/rust-typestate/ https://yoric.github.io/post/rust-typestate/
- funcDropShadow 3y ago> "or it could push the task of precondition checking to its caller, and enforce via types" Or the enforcement could be done via a design-by-contract discipline. Either using classical assertions, or something like reusable specifications in Clojure's spec library.
- tengbretson 3y agoBranded types in typescript are an interesting way to do this.
- aeonik 3y agoIsn't the intention of public vs private supposed to cover the "tagging of code for a particular context"? Or maybe we need more specific semantics around this that can cross cut the domain that public and private (and protected in .NET ecosystems) cover?
- nerdponx 3y agoSure, this could definitely be a generalization of "public" and "private".
- jon-wood 3y agoYou can use types for this in Python: def do_something(position: ValidatedPosition): ... position = Position(x, y) if abs(position.x) > 1 or abs(position.y) > 1: raise ValueError("out of bounds") position = ValidatedPosition(position.x, position.y) do_something(position) In practice I'd probably have ValidatedPosition encapsulate that validation in it's constructor, but you get the idea. If you attempt to pass a Position to do_something then mypy is going to shout at you for passing an object of the wrong type to it. Python's type checking definitely isn't as comprehensive as something like Rust, but I have found it increasingly useful for situations like this where you want to ensure the data being passed in has been appropriately handled.
- nerdponx 3y agoSure, but now you have to rewrite your code to accept a ValidatedPosition as input and not two floats. In Rust that might be "zero cost", but in Python it certainly is not, even if you implement all this in Cython. And even in the context of a Cython/Rust/whatever extension, what if this is calling a BLAS function or some other external procedure that has specific preconditions? It's usually fine to use the type state pattern as described by sibling commenters, but sometimes it's expensive or infeasible. Another situation is less related to performance and more related to managing dynamic state. Consider the following annoyance that arises when we try to attach static types to an old-school Python idiom: class AppClient: def __init__(self, url: str, http_client: httpx.Client | None = None) -> None: self.url = url self.http_client = httpx.Client() if http_client is None else http_client self._auth_token: str | None = None def login(self) -> None: self._auth_token = ... def _post(self, body: dict[str, Any]) -> None: self.http_client.post( self.url, headers={"Authorizaton": f"Bearer {self._auth_token}"}, json=body ) def submit_foo(self, foo: Foo) -> None: self._post(foo.to_dict()) Users will end up with a difficult-to-debug auth failure if they forget to call `login` first. Also, Mypy won't catch this without contorting your code. We naturally would want to add a test case like this: def test_error_if_not_logged_in(): app_client = AppClient(url="https://api.example.net/v1") with pytest.raises(RuntimeError): app_client._post({}) Thus we'll need to guard every single usage of `self._auth_token` with: if self._auth_token is None: raise RuntimeError(...) And there are three usual approaches for handling this: 1. Disregard the article's advice and push the "if" down, into the _post method, to ensure that we only actually need to write that guard condition once. 2. Write a _check_logged_in method that encapsulates the if/raise logic, use that to at least make it easier to apply the guard where needed. 3. Refactor the internals to use something akin to the type state pattern, which is a funky and cool idea that I support. But it could easily end up turning into an "enterprise fizzbuzz" exercise where your little client library expands into a type-driven onion that obfuscates the business logic. I'm proposing a 4th way: 4. Declare that _post may only be called from inside a specific dynamic context, and ensure that that dynamic context is only established by login(). Is it better? I'm not sure. But I've never seen it before, and it's kind of appealing as I continue to think about it.
- garethrowlands 3y agoThe “push ifs up” advice is not about checking preconditions, it’s about selecting the right code path. If you have a function with a precondition, by all means assert it at the beginning of that function. For example, the Java type system allows nulls, so a function that requires an object should throw an exception if a null is passed. Each call site is absolutely responsible for ensuring that it only calls a function if its precondition is true. This follows from the definition of precondition. Nothing can change that. Calling a function in violation of its precondition is a bug (in the caller). And, sure, functions often need code that checks that (to prevent undefined behaviour). But we should distinguish these assertions from our program’s actual control flow. The article is about the latter.