Consideration of `pureread()`, `purewrite()`, `purefunc()` builtins

First of all, I asked about pureread, not purewrite. I don’t understand what purewrite is, but my impression is that it cannot modify external state. And yet this function can clearly modify external state - pass a List[List[S]] as refs, where S is a string subclass with an overridden startswith method that changes the process CWD (or does anything else that’s modifying external state).

Also, this code is weird: You have

        is_purewrite: bool = False
        if not is_purewrite:
            mod_oa_doc = oa_doc
        else:
            mod_oa_doc = deepcopy(oa_doc)

What’s the point of that if statement? is_purewrite is always false, so the else branch will never get taken.

Your response isn’t addressing my question at all, and seems to be just adding yet more confusion to an already confused proposal.

Thanks, Chris - you made my point more clearly than I did.

2 Likes

That’s not pure. Subtraction can be user defined in a custom class, so it’s absolutely possible to call fib in such a way that it does anything, including modifying external state.

1 Like

That’s right.

I am trying to communicate about the int case, but, yes, if that gets overridden with behavior which reads or writes outside of the local scope then it would fail the related purity test.

What’s right? Are you agreeing that these functions can be defined as always returning False? Because if not, Chris (and I) asked you to “show a function”, and the only one you showed was fib, which isn’t pure in any way.

I suspect that your problem here is that you have a very oversimplified view of the Python execution model. Chris and I both have a lot of experience with the weird edge cases of the language, and what we’re trying to say is that your proposal won’t work in general, and unless you address that, it stands no chance of being accepted.

But you can’t guarantee you’ll get passed an int, so that’s not realistic. Even if you could ensure an int, people can write an int subclass, so you have to take that into account.

So let me reiterate. By your own admission, pureread(fib) is False (because it could be passed a non-int value). So please provide a function that returns True - you haven’t done so yet.

2 Likes

Counterexample:

counter = 0
class Tracker(int):
    def __sub__(self, other):
        print("I'm not pure!")
        global counter; counter += 1
        return Tracker(int(self) - other)

print(fib(Tracker(10)))

So it is both trivial AND not guaranteed to be pure.

2 Likes

And if you can guarantee that you’ll get passed an int, you can simply require that the function be pure, and call it done. That’s what most use-cases need. For example, list.sort(key=somefunc) will assume that the function provided doesn’t mutate the elements given to it. Does it need a decorator to confirm that? No, it’s just part of sort()'s contract.

?? What’s right? Are you saying that my implementations are right?

Hold on, please. I am trying to fix how I’m approaching this, and replying as you’re asking.

I’ll try this again.

I want to ask for builtins with the signatures

def pureread(f: Callable) -> bool:
    return False  # TODO
def purewrite(f: Callable) -> bool:
    return False  # TODO
def purefunc(f: Callable) -> bool:
    return pureread(f) && purewrite(f)

Applying to the function

def fib(n: Any) -> Any:
    if n < 1:
        return n
    return fib(n-1) + fib(n-2)

With consideration of the following type

COUNTER = 0
class OpCounter(int):
    def __sub__(self, other):
        global COUNTER
        COUNTER += 1
        return OpCounter(int(self) - other)

On the following examples

is_int_pureread = pureread(fib(3))
is_OpCounter_pureread = pureread(fib(Tracker(3)))

print(is_int_pureread)
print(is_OpCounter_pureread)

Would print the following:

True
False

Because of the rationale on the former

Call fib on `n`
    pass by value type int 3, `n` tagged as local
    read local `n`  # READ LOCAL
    load constant `1` #READ LOCAL
    Call __le__ on `n`, `1`
        pass by value type int 3, `self` tagged as local
        pass by value type int 1, `other` tagged as local
        create temporary, tagged as local
        assign temporary to bool OP_LESS_THAN_OR_EQUAL `self` value of 3, `other` value of 1, results in False #READ  LOCAL, READ LOCAL, WRITE LOCAL
        return temporary # RETURN
    temporary assigned to type bool with value from return as False  # WRITE LOCAL
    OP_JUMP_IF_FALSE temporary, FALSE_ADDRESS  # READ LOCAL
    FALSE_ADDRESS
    read local `n`  # READ LOCAL
    load constant 1  # READ LOCAL
    call __sub__ on `n`, 1
        pass by value type int 3, `self` tagged as local  
        pass by value type int 1, `other` tagged as local
        create temporary, tagged as local
        assign temporary to int OP_SUBSTRACT `self` value of 3, `other` value of 1, result of 2,  # READ LOCAL, READ LOCAL, WRITE LOCAL
        return temporary  # RETURN
    temporary assigned to type int with value from return as 2  # WRITE LOCAL
    create temporary_fib_2
    Call fib on `n`  # SAME PATTERN AS TOP LEVEL CALL
        pass by value type int 2, `n` tagged as local
        ...
    temporary_fib_2 assigned to type int with value 1  # WRITE LOCAL
    read local `n` # READ LOCAL
    load constant 1 # READ LOCAL
    call __sub__ on `n`, 2
        pass by value type int 3, `self` tagged as local
        pass by value type int 2, `other` tagged as local
        create temporary, tagged as local
        assign temporary to int OP_SUBSTRACT `self` value of 3, `other` value of 2, result of 1  # READ LOCAL, READ LOCAL, WRITE LOCAL
        return temporary
    temporary assigned to type int with value from return as 1  # WRITE LOCAL
    create temporary_fib_1
    Call fib on `n`
        pass by value type int 1, `n` tagged as local
        read local `n`  # READ LOCAL
        load constant 1  # READ LOCAL
        Call __le__ on `self`, `other`
            pass by value type int 1, `self` tagged as local
            pass by value type int `, `other` tagged as local
            create temporary, tagged as local
            assign temporary to bool OP_LESS_THAN_OR_EQUAL `self` value of 1, `other` value of 1, result of True  # READ LOCAL, READ LOCAL, WRITE LOCAL
            return temporary
        temporary assigned to type bool with value False  # LOCAL WRITE 
        OP _JUMP_IF_FALSE, temporary, FALSE_ADDRESS  # LOCAL READ 
        read local `n`  # LOCAL READ
        return `n`  # RETURN
    temporary_fib_1 assigned to type int with value 1  # WRITE LOCAL
    create temporary, tagged as local
    call __add__ on `self`, `other`
        pass by value type int 1, `self` tagged as local
        pass by value type int 1, `other` tagged as local
        create temporary, tagged as local
        assign temporary to int OP_ADDITION `self value of 1, `other` value of 1, result of 2  # READ LOCAL, READ LOCAL, WRITE LOCAL
        return temporary
    temporary assigned to type int with value 2  # WRITE LOCAL
    return temporary

Because of the rationale on the latter

Call fib on `n`
    pass by reference type OpCounter 3, `n` tagged as local
    read local `n`  # READ LOCAL
    load constant `1` #READ LOCAL
    Call __le__ on `n`, `1`
        pass by value type OpCounter 3, `self` tagged as local
        pass by value type int 1, `other` tagged as local
        create temporary, tagged as local
        assign temporary to bool OP_LESS_THAN_OR_EQUAL `self` value of 3, `other` value of 1, results in False # READ  LOCAL, READ LOCAL, WRITE LOCAL
        return temporary # RETURN
    temporary assigned to type bool with value from return as False  # WRITE LOCAL
    OP_JUMP_IF_FALSE temporary, FALSE_ADDRESS  # READ LOCAL
    FALSE_ADDRESS
    read local `n`  # READ LOCAL
    load constant 1  # READ LOCAL
    call __sub__ on `n`, 1
        pass by reference type OpCounter 3, `self` tagged as local  
        pass by value type int 1, `other` tagged as local
        reference global CONSTANT 
        load constant of type int 1
        call __iadd__ on `self`, `other`
            pass by reference type int, `self` tagged as external
            pass by value type int, `other` tagged as local
            create temporary
            assign `self` to int OP_ADDITION `self` value of 0, `other` value of 1, results in 1  # READ EXTERNAL, READ LOCAL, WRITE EXTERNAL
        create temporary, tagged as local
        assign temporary to int OP_SUBSTRACT `self` value of 3, `other` value of 1, result of 2,  # READ LOCAL, READ LOCAL, WRITE LOCAL
        assign temporary to type OpCounter of value 2  # LOCAL WRITE, INTENTIONAL OMISSION OF `OpCounter__init__()`
        return temporary  # RETURN
    temporary assigned to type OpCounter with value from return as 2  # WRITE LOCAL
    create temporary_fib_2
    Call fib on `n`  # SAME PATTERN AS TOP LEVEL CALL
        pass by reference type OpCounter 2, `n` tagged as local
        ...
    temporary_fib_2 assigned to type OpCounter with value 1  # WRITE LOCAL
    read local `n` # READ LOCAL
    load constant 1 # READ LOCAL
    call __sub__ on `n`, 2
        pass by reference type OpCounter 3, `self` tagged as local
        pass by value type int 2, `other` tagged as local
        reference global CONSTANT 
        load constant of type int 1
        call __iadd__ on `self`, `other`
            pass by reference type int, `self` tagged as external
            pass by value type int, `other` tagged as local
            create temporary
            assign `self` to int OP_ADDITION `self` value of 2, `other` value of 1, results in 3  # READ EXTERNAL, READ LOCAL, WRITE EXTERNAL
        create temporary, tagged as local
        assign temporary to int OP_SUBSTRACT `self` value of 3, `other` value of 2, result of 1  # READ LOCAL, READ LOCAL, WRITE LOCAL
        assign temporary to type OpCounter of value 1  # LOCAL WRITE, INTENTIONAL OMISSION OF `OpCounter__init__()`
        return temporary
    temporary assigned to type OpCounter with value from return as 1  # WRITE LOCAL
    create temporary_fib_1
    Call fib on `n`
        pass by reference type OpCounter 1, `n` tagged as local
        read local `n`  # READ LOCAL
        load constant 1  # READ LOCAL
        Call __le__ on `self`, `other`
            pass by value type OpCounter 1, `self` tagged as local
            pass by value type int `, `other` tagged as local
            create temporary, tagged as local
            assign temporary to bool OP_LESS_THAN_OR_EQUAL `self` value of 1, `other` value of 1, result of True  # READ LOCAL, READ LOCAL, WRITE LOCAL
            return temporary
        temporary assigned to type bool with value False  # LOCAL WRITE 
        OP _JUMP_IF_FALSE, temporary, FALSE_ADDRESS  # LOCAL READ 
        read local `n`  # LOCAL READ
        return `n`  # RETURN
    temporary_fib_1 assigned to type OpCounter with value 1  # WRITE LOCAL
    create temporary, tagged as local
    call __add__ on `self`, `other`
        pass by value type OpCounter 1, `self` tagged as local
        pass by value type OpCounter 1, `other` tagged as local
        create temporary, tagged as local
        assign temporary to int OP_ADDITION `self` value of 1, `other` value of 1, result of 2  # READ LOCAL, READ LOCAL, WRITE LOCAL
        return temporary
        assign temporary to type OpCounter of value 2  # LOCAL WRITE, INTENTIONAL OMISSION OF `OpCounter__init__()`
    temporary assigned to type OpCounter with value 2  # WRITE LOCAL
    return temporary

In the former, all statements are able to be verified as local at runtime from the bytecode generated with the passed arguments. In the latter, some are able to be marked as reading and writing to external state. So the former when called in purefunc(fib(3)) would return True while the latter when called in pureread(fib(OpCounter(3))) would return False and purewrite(fib(OpCounter(3))) would return False.

Please tell me this is what you’ve been asking for.

Presumably it would have to be something like pureread(fib, 3) since otherwise all pureread() will see is the -2 it’s given. But that’s fairly immaterial.

On that last line you’re accessing the non-local variable fib in this function so, even when constrained to ints, this isn’t pure either. Any function that calls another function is automatically unpure[1].


  1. possibly except if the callee function is defined inside the caller ↩︎

1 Like

We could view functions as copied references, where if the holding variable gets reassigned then that’s a write externally but if we’re calling it, then we’re just touching the copied value. Maybe?

This is precisely what people meant when they said it’s a major change to Python’s semantics.

Either you’re changing how function calls within another function work, or your purexxx functions give wrong results because they make incorrect assumptions about how Python works.

5 Likes

This is actually something I’ve abused a lot for caching. I have some expensive operation that creates a list of registered objects and then as the program runs I can register more to the cache. Otherwise I’d have to have some global cache object.

You do; it’s the function :shushing_face:
(I understood your meaning though; you like the functools cache interface)


Purity and being side-effect free are useful properties. Code which doesn’t have side effects, within reason, can be easier to reason about, to parallelize, to test, etc etc…

Which is why frozen objects (frozen dict, pyrsistent, py-rpds, frozen dataclasses, etc etc etc) are so popular. Because they guarantee that a certain class of impure behaviors are forbidden. That is the way that people achieve architectures and abstractions which benefit from forbidding (certain) side effects in a language as dynamic as Python.

It’s not the same as having definitionally pure functions, but as this thread well demonstrates: those don’t exist in Python. I think about this each time this topic comes up. IMO we should really be directing anyone who is looking for purity on the callee side to instead consider immutability on the caller side.

1 Like

Would something better be syntax to require only immutable arguments?

That can certainly help, although it always depends on context. What this thread has proven is that you cannot analyze a Python function and say with certainty that it is pure; but it is still possible to document that a function should be side effect free. The “consenting adults” policy applies here.

Broadly speaking, I would divide the need for purity into two camps:

  1. I’m offering you something that only makes sense if given a pure function. However, if you break that rule, well, it’s your environment, it’s your breakage. Enjoy.
  2. I’m making a thing, and you get to provide a pure function that does something. It’s MY environment, and I need to protect it. I’m not going to accept a Python function; instead, I will execute something on your behalf in some way.

In the first form, it doesn’t really bother me if you give me an impure function, so I won’t even test for it. Most likely, any impurity in the function is for testing purposes; consider sorting a list using cmp_to_key, and sticking a print() call inside your comparator function. Is it impure? Well, yes, of course it is. But that’s not really list sort’s issue. That’s up to you. Nothing wrong with that.

In the second form, which is much much rarer, I’m not going to let you write Python code at all. That’s where I might design my own extremely cut-down notation, build an AST out of it, and then interpret that. Think something like an equation solver, where you allow someone to do arithmetic and maybe trignometric functions, but you don’t want them to create classes. There’s really no reason to use Python for this, so it’s better to define your own mini-language.

Purity is a concept that doesn’t align well with Python’s extreme flexibility, but it’s still useful in its right context.

2 Likes

Sure, but I’m having trouble seeing how the proposed pureread function
would be useful in practice, because at best it verifies purity when the
function is called with one particular set of arguments. What you really
want is to verify purity when called with any arguments of the types
it’s expected to handle.

That sounds like something a type checker or other static analysis tool
should be doing rather than a runtime function.

While I agree, specification compliant type checkers are unlikely to ever be capable of this, unless we start over with a “type system 2.0”.

Typecheckers still don’t handle the binary operators or descriptors well (and handling them well is a prerequisite to understanding purity) , and completely fail to enforce LSP constraints on collections.abc.Hashable, with some people suggesting it shouldn’t be used even when accurate because of that.

They also can’t handle try/except patterns for missing keys on typed dicts, with type checker authors actively resistant to supporting that.

The current subtyping rules also assume every addition is strictly additive (this contributes to the issue with typecheckers and hashability). They aren’t going to suddenly handle an effects system and enforcing consistency with declared limitations on effects.

It’s unrealistic to see this ever modeled in python’s static typing in a way that’s any more useful than the current “just document it” (which, for the record, I think is a perfectly fine option)

1 Like

First off, I want to note 100% agreement with the “just document it” solution being adequate.

I don’t think you should ever document that a function is pure or a callback must be pure – instead, document what your code actually does such that this matters, and let your consumers decide how to engage with that information.

One thing I don’t want to be lost here is that, for the cases where immutability really is a good fit, type checkers are perfectly able, today, to restrict callers to using specific immutable types.

def ascending(xs: tuple[int, ...]) -> tuple[int, ...]:
    return tuple(sorted(xs))

I have used this approach plenty and never had issues with it. Although there are fancier solutions which maybe the type system could be extended to encode (and, per above, that’s very unlikely to happen), I think “just annotate it as a tuple or frozen dict” is basically the bar to beat.

1 Like