Proposal: `typing.no_discard`, a decorator to indicate that the return value should not be discarded

I’d really love a no_discard or a must_use decorator.

I’ve seen multiple times in our codebases that there were bugs because people were working with immutable data and in one place, instead of using state = state.with_xyz(5) they had just state.with_xyz(5).

2 Likes

I don’t really think it’s worth making, as users can basically do anything with functions.
For example I sometimes did

try:
    int(val)
except ValueError: ...

which is a totally valid use case IMO.
Also, when we were to do x = function_with_no_discard(), what guarantees whetherx is used later on or not. And even there it could again be dynamic (using exec, manipulating the namespace, using it conditionally, …).

We’ll never know wether to throw an error or not.

If we, for example do func(function_with_no_discard(), …) some random (keyword) argument being given can influence wether the return value is actually used. Should checkers really go that deep into the function (perhaps even more ones later on) just to check that? What if the result is only used if some random API, returns something specific.

Python, generally is way to dynamic for type checkers to not have to guess sometimes. But introducing even more cases where they’d have to guess doesn’t make it better.

And those making that kind of error a lot of times are, mostly beginners. Once you use type-checkers, you’ll automatically know wether the return value is None or not.

2 Likes

If the result is assigned to a variable, even if it _, or if the variable is never used, that’s still counted as used. If x isn’t used, you’ll probably get another warning about an unused variable so there is no need for duplicate warnings. If you assigned it to _ that shows you made a conscience effort to discard it so it’s probably fine.

1 Like

I get this problem each time I require to remind myself if the function I call is an in-place modifier instead of a transform-and-return function. But that latter case is the regular case and the problem arises when an in-place modificator is involved.
One of these statements is nonsense, I can never remember which one :

list2 = sort(list1)
list3 = list1.sorted()

Imo the special rule should be stated on the ‘invalid return’ rather than on the ‘nodiscard’ one.
In this regard, this topic is related : "Wrong" Special Form

2 Likes

Agreed, and I want to underline that I really don’t think you need to invent an esoteric use-case to arrive at common real-world examples that this could help with.

This exact goof-up still occasionally bites me, particularly if I’ve just come from reading pseudocode or a different language and my attention slips for a second:

foo = "this is a string"
# ...
foo.replace(" ", "_")
# ...
print(foo)

That call to str.replace is perfectly valid code. And unless I’ve misconfigured something, neither pylint nor Pylance report that there’s anything wrong with it.

But I can remember a couple of times where the call to replace was one small step of a larger string-processing function and there weren’t any other calls to replace nearby, so the call looked correct at first glance and didn’t stand out as “not like the others”; as a result, the bug took an embarrassing amount of time to find.

I don’t have the technical chops to boldly assert that the no_discard decorator is the best, perfect solution to this, but I can at least say that some way to flag these as “you probably goofed this” would save real time for real people.

6 Likes

what if you want to determine if a Packet can be constructed from bytes, but not to use?

can_packet_return_file = True
Try:
    Packet.from_bytes(some_bytes)
Except:
   can_packet_return_file = False

Should be correct typed code.

You would then need to assign the return value to _, like this:

can_packet_return_file = True
try:
    _ = Packet.from_bytes(some_bytes)
except:
    can_packet_return_file = False
2 Likes

As Thomas mentioned, it’s good practice to explicitly discard return values with _

1 Like

Just want to add a comment in favor of adding must_use or nodiscard . I work in a code base where we’ve found it useful to use a result type Result[T, E] as a return type in many places, because it serves as a strong nudge for the caller to handle the error and can be (mostly) enforced with type checking.

I say mostly, because while our Python Result type is similar to Rust’s Result type or C++'s std::expected, both of those benefit from a build-time check that the value is used, but the ability to do that that in Python is lacking. If the caller simply discards the Result value that is returned, there’s no feedback that an error case is being ignored. Adding must_use to Python’s typing library would complete Python’s ability to support this style of error handling.

2 Likes

I love this discussion. I just put together a simple POC must_use decorator (with AI looking over my shoulder haha)

from functools import wraps
import logging
from typing import Callable, Generic, TypeVar, ParamSpec

T = TypeVar( 'T' )
P = ParamSpec( 'P' )

class MustUse( Generic[T] ):
	def __init__( self, value: T ) -> None:
		self._value = value
		self._used = False
	
	def unwrap( self ) -> T:
		"""Marks the value as used and returns it."""
		self._used = True
		return self._value
	
	def __repr__( self ) -> str:
		raise Exception( 'access denied: call .unwrap() on a MustUse object' )
	
	def __str__( self ) -> str:
		raise Exception( 'access denied: call .unwrap() on a MustUse object' )
	
	def __del__( self ) -> None:
		if not self._used:
			cls = type( self._value )
			logging.critical(
				f"Unhandled MustUse object containing: {cls.__module__}.{cls.__qualname__}"
			)

def must_use( func: Callable[P,T] ) -> Callable[P,MustUse[T]]:
	@wraps( func )
	def wrapper( *args: P.args, **kwargs: P.kwargs ) -> MustUse[T]:
		result = func( *args, **kwargs )
		return MustUse( result )
	return wrapper

if __name__ == '__main__':
	@must_use
	def add1( foo: int ) -> int:
		return foo + 1
	
	print( add1( 10 ).unwrap() ) # prints 11
	print( add1( 20 )) # triggers both logger.critical and an exception

This implementation does not have much to do with the current proposal as it performs a runtime check, whereas the proposal is for static type checking. In addition there is nothing stopping a developer from currently calling your .unwrap() method and then not using the value it returns.

4 Likes

I’m curious why you think it’s unrelated. Someone mentioned there’s no support for it in Python and while this very basic and lacks features that it should have, it demonstrates a potential path towards runtime support which could lead to mypy support. And there’s nothing stopping you from calling .unwrap() in rust and ignoring it either. That is actually a valid solution. Sometimes you really don’t care about a result, but the bigger problem is when you don’t realize that there’s a result that maybe you should care about. Making a lot of noise to try to force the developer to deal with the result, at least by actively throwing it away, helps catch those cases.

.unwrap() is entirely unrelated to #[must_use] in Rust. .unwrap() is common on container types like Result and Option to assert that the value is successful or present respectively, and to panic otherwise.

Option<T> isn’t annotated with #[must_use] so it can be ignored, unwrap or not. Result<T, E> is annotated with #[must_use] so it cannot be ignored without let _ or unwrapping.

#[must_use]
fn foo(i: u16) -> u16 {
    i * i
}

fn opt(i: u16) -> Option<u16> {
    Some(i)
}

fn result(i: u16) -> Result<u16, String> {
    Ok(i)
}

fn main() {
    opt(1).unwrap(); // NOT a compile time _warning_
    opt(2); // NOT a compile time _warning_
    result(3); // Compile time _warning_ because Result is annotated with #[must_use]
    result(4).unwrap(); // NOT a compile time warning because it uses the Result, then ignores the Ok value
    foo(3); // Compile time _warning_ because we annotated it with #[must_use]
    let _ = foo(4); // NOT a compile time _warning_ because we explicitly throwaway the result with `let _`
    println!("Hello World");
}

See the above in Rust Playground

1 Like

It sounds like calling it unwrap might confuse people that have difficulty understanding that Python and rust aren’t the same thing. Perhaps you can suggest a better name, because I only know enough rust to know that I never want to touch it again beyond the one app I have in production. What I built here is just a PoC demonstrating one way to implement runtime detection of missing error handling.