Skip to content

Consider loosening restrictions of Final #920

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
DetachHead opened this issue Nov 4, 2021 · 9 comments
Open

Consider loosening restrictions of Final #920

DetachHead opened this issue Nov 4, 2021 · 9 comments
Labels
topic: feature Discussions about new features for Python's type annotations

Comments

@DetachHead
Copy link

DetachHead commented Nov 4, 2021

I think that Final is a very based idea, but I feel that it's current restrictions make using it painful.

I think that split assignments should be allowed:

def foo(cond: bool) -> None:
    if cond:
        a: Final = Funny("spam")
    else:
        a: Final = Funny("eggs")  # this is currently an error

Most languages allow this pattern to initialize constant variables:

def foo(cond: bool) -> None:
    a: Final[Funny]
    if cond:
        a = Funny("spam")
    else:
        a = Funny("eggs")

I think these are convenient and enables Final to be used in more scenarios, but this is currently an error.
But this is allowed:

def foo(cond: bool) -> None:
    a: Final = Funny("spam") if cond else Funny("eggs")

Another issue I've found is the prohibition of Final declarations within loops:

def foo() -> None:
    for i in [1,2,3]:
        a: Final = i * 2  # currently an error
        a = 10  # error
        print(a)
    print(a)
    a = 10  # error

This is defined in the pep:

Note that a type checker need not allow Final declarations inside loops since the runtime will see multiple assignments to the same variable in subsequent iterations.

I understand that at runtime the same variable is reused for each iteration of the loop, but if you ignore that fact and just look at the semantic meaning of the code, it matches exactly to this Kotlin example:

fun foo() {
    val a = listOf(1, 2, 3).map { i ->
        val a = i
        a = 10  // error
        println(a)
        a
    }.last()
    println(a)
    a = 10  // error
}

Disallowing Final in loops doesn't address any of the motivations listed in the pep, any I can't see any reason why it should be disallowed.

Also, why are Final annotations not allowed on functional arguments?

Final may only be used as the outermost type in assignments or variable annotations. Using it in any other position is an error. In particular, Final can't be used in annotations for function arguments:

Why? This is commonly seen in other languages. In Kotlin, function parameters can only be val(Kotlin's form of Final), in Java a function parameter may be marked with final.

@srittau srittau added the topic: feature Discussions about new features for Python's type annotations label Nov 4, 2021
@JukkaL
Copy link
Contributor

JukkaL commented Nov 4, 2021

A for loop doesn't create a new scope for variables, so if we'd allow Final within for loops, the value of a Final variable could change. The Kotlin example seems quite different, as there are two separate definitions named a. This slightly modified example works differently:

fun foo() {
    val x = listOf(1, 2, 3).map { i ->
        val a = i
        a = 10  // error
        println(a)
        a
    }.last()
    println(a) // error: a is no longer in scope here
}

Why? This is commonly seen in other languages. In Kotlin, function parameters can only be val(Kotlin's form of Final), in Java a function parameter may be marked with final.

I vaguely remember that the reasoning went like this: Since most function arguments aren't assigned to, there was a worry that some people would annotate almost all arguments using Final, while most others would never use Final for function arguments, resulting in to quite different-looking (but semantically equally "correct") styles of defining functions. This would arguably go against "there should be one obvious way to do things".

Personally I think that annotating local variables whenever possible with Final declarations is too verbose and noisy, and it makes type-annotated code look quite different from unannotated Python. One of the original goals of the type hinting syntax was to keep it lightweight, so that annotated and unannotated code mostly look and feel similar.

@hmc-cs-mdrissi
Copy link

If the issue is most function arguments are not mutated then I'd be happy to see a Mutable type that means opposite of Final and have function arguments default to meaning Final (with relevant error messages hidden behind a config setting/flag for backwards compatibility). Having a way to minimize functions that are doing mutations and avoid accidental mutations would be nice feature.

i think marking almost all arguments as Final would be noisy enough to get readability complaints.

@KotlinIsland
Copy link

@JukkaL Thanks for the response!

The Kotlin example seems quite different

I disagree, the Kotlin example is contrived to achieve the exact same semantic meaning as the Python code. Created to communicate that despite Python function-scoping the symbol, it still remains semantically consistent with Final.

If you were forget about the fact that the same variable is reused for the iteration, and instead think of it as:
"a new variable is created on each iteration, and on the last iteration, that variable is promoted to the function scope".
This "pseudo description" matches the type-time behavior of this example, and is what I was demonstrating in the Kotlin example.

def foo() -> None:
    for i in [1,2,3]:
        if "a" in vars():
              print(a)  # error: runtime okay, but from a type perspective a is not defined yet
        a: Final = i * 2  # currently an error
        a = 10  # error
        print(a)
    print(a)
    a = 10  # error

In this code we can see that within every iteration of the for block, a will always have a constant value, and once the for is completed, a will continue to have a constant value.

I can see no way in which a Final value could be incorrectly reassigned within the for loop, or outside it.

"there should be one obvious way to do things".

Fair enough, would be nice if the pep had that info in it. I would love a final-by-default setting for function arguments like @hmc-cs-mdrissi proposed.

@hmc-cs-mdrissi
Copy link

I've run into one issue today that makes me wish for final function arguments. A generic type being final can affect what variance could be.

List[T] is invariant, but Final List[T] would be safe to have as covariant. I'm aware of Sequence[T] for covariant, but there are libraries that work with lists and not other sequences that never mutate the list and could be marked Final List[T]. tensorflow is one library that has a lot of functions that work on lists/tuples and not other sequence and list invariance complicates annotations a good amount even though it would be safe for it to be covariant as none of the functions mutate the list.

This would require TypeVar to also include information about how final affects variance. Something like TypeVar('T', final_covariant=True) could be used for list[T]. Maybe there exists a final_contravariant too, but unsure on an example for it.

@KotlinIsland
Copy link

KotlinIsland commented Feb 6, 2022

@hmc-cs-mdrissi Final list is still mutable and invariant.

Perhaps you would want a protocol that matches list but without the mutation bits?

@erictraut
Copy link
Collaborator

erictraut commented Feb 7, 2022

As @KotlinIsland said, Final[List[T]] does not mean that the list is immutable. It means that the reference to the list is immutable — i.e. you cannot assign a different list to the field or variable that is annotated as Final. If you want to indicate that a list is immutable, you should use Sequence instead. Its type parameter is covariant.

@hmc-cs-mdrissi
Copy link

The problem with Sequence is there are libraries that only work with lists (including isinstance checks occasionally), but never mutate it. That's not possible to describe currently. So my options are lie and use Sequence and let it crash if you pick wrong Sequence type or use list and run into variance issues. My main challenge is this is not my library (tensorflow), but an existing major open source library and it'd likely be difficult to change implementation to support other sequences.

A protocol that only matches list - mutation parts I think would work.

@KotlinIsland
Copy link

Not exactly what you want though, because you could still send a non-list and get a fail. Ideally you want an ImmutableList which would be covariant but still nominal.

@KotlinIsland
Copy link

This is what use site variance modifiers(in/out) are useful for in Kotlin:

fun foo(someList: MutableList<out Number>) {
    someList.clear()  // valid
    someList.add(1.1) // Type mismatch: Inferred type is 'Float' but 'Nothing' was expected
}
val someList: MutableList<Int> = mutableListOf(1, 2, 3)
foo(someList) // no error

Here the argument is now covariant, but is still mutable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
topic: feature Discussions about new features for Python's type annotations
Projects
None yet
Development

No branches or pull requests

6 participants