[Python] Typing of optional instance variables: Assign None vs. only define type [closed]

Discussão em 'Python' iniciado por Stack, Janeiro 21, 2025.

  1. Stack

    Stack Membro Participativo

    Today I was discussing Python 3.10+ typing best practice for optional instance variables with a colleague. While I tend to only define optional instance variables to avoid using ambiguous types at the cost of slightly more "interface" code, he typically assigns them None, irritating MyPy but having "clean" variable access.

    tl;dr


    What's the best practice (PEP or anything official?) to define optional instance variables:

    • mixed/ambiguous/implicit typing but simple var is None checks
    • clean/unambiguous/explicit typing but slightly more complex variable interface with getattr, try/except, err raising required

    Looking at other programming languages like C++ or the Zen of Python ("explicit is better than implicit"), I'd say that types should be as explicit/explicit/concise as possible, even if this is at the cost of slightly more interface.

    I haven't found anything explicit about this in the official Python documentation or PEP style guide. The official Python documentation is only clear about using None for optional function arguments, but not regarding instance attributes. The doc. of the typing module doesn't give any details at all on this topic.

    Do you know of any resources giving more information?

    Method 1: Assigning None to optional instance variables


    We may assign None to optional variables, giving them two different types but simplifying checks if the variable is set:

    class A:
    def __init__(self, var_a: int | None = None) -> None:
    self.optional_a: int | None = var_a # assigned now or later - if ever
    self.optional_b: str | None # to be assigned later - if ever

    def maybe_assign_b(self, var_b: str) -> None:
    self.optional_b = var_b

    def do_something(self) -> str:
    n_reps: int = 5 if self.optional_a is None else self.otional_a

    if self.optional_a is None: # <<--- very nice to do this
    return self.optional_b * 5
    # else
    return 'X' * n_reps

    def get_b(self) -> str | None: # <<--- very, very, very ugly
    return self.optional_b

    a = A(7)
    # MyPy fails here. In this case the solution would be trivial: my_b: str | None
    my_b: str = a.get_b()

    Con:

    • Results in ambiguous types, which will give you headaches in large packages. Even lots of type narrowing, casting, ... won't work out. In some cases - especially overloaded function warnings -, only using # type: ignore will silence the errors/warnings.
    Pro:

    • Simple check with if ... is None to see if the variable is assigned a value or not.
    • The user won't see any error when he tries to access "unassigned" variables (well, they are assigned None...). Also I personally prefer raising explicit errors, so this is so-so.
    Method 2: Only defining types of optional instance variables


    In my opinion, types should be as concise, unambiguous and simple as possible. Thus, only defining the variables but not assigning them unless required is more concise and provides you with explicit unambiguous types

    class A:
    def __init__(self, var_a: int | None = None) -> None:
    # this is slightly extreme, but I'll add this for completeness:
    # Typically I'd rather find a useful default. If there is none, I indeed tend to assign them None...
    if var_a is not None:
    self.optional_a: int = var_a # assigned now or later - if ever
    else:
    self.optional_a: int # assigned now or later - if ever

    # But this should be left unassigned imho:
    self.optional_b: str # to be assigned later - if ever

    def maybe_assign_b(self, var_b: str) -> None:
    self.optional_b = var_b

    def do_something(self) -> str:
    # Using getattr with default (my preferred option)
    n_reps: int = getattr(self, 'optional_a', 5)

    try: # <<-- always having to use this may be cumbersome
    return self.optional_b * n_reps
    except AttributeError:
    return 'X' * n_reps

    def get_b_v1(self) -> str: # <<--- clean, but will raise an error
    return self.optional_b

    def get_b_v2(self) -> str: # <<--- clean, but we may also use str | None
    # Clean, but only possible if you have a default.
    # (Then why not set it in __init__? Well, some times there are special cases...)
    try:
    return self.optional_b
    except AttributeError:
    return 'X' # we may also return None if required, but then mixed types again...

    # We could also use hasattr or properties...

    a = A(7)
    # MyPy is fine with this, but an error will be raised (which is ok imho):
    my_b: str = a.get_b()

    Con:

    • Slightly more lines of code to check variable existence.

    If used frequently, refactoring will mitigate this. Also new constructs like the walrus operator may help simplifying the code, f.i.:

    if (_b := getattr(self, 'optional_b', None)) is not None:
    return _b * some_other_var
    else:
    # do something else

    Pro:

    • Explicit, clean and precise typing. MyPy will correctly get this.
    • In accordance with many Python built-in methods or standards used by widely-distributed packages (return type is almost always of a single type(group), f.i. numeric scalars or arrays, but not both).
    • What hasn't been assigned isn't there (imho good).
    • Several possibilities to check for existence resp. if the variable is set/defined, f.i. getattr, hasattr, try/except, @property.

    So do you know if there is a best practice for this?

    Continue reading...

Compartilhe esta Página