DefaultPromise violates encapsulation

Java’s CompletableFuture deals with this by ensuring that completion is atomic and only the first result ‘wins’. Further completions do not modify the result.

2 Likes

The problem isn’t completing it twice, the problem is that it was going to be completed in one way, but because it was accessed as a Promise, it was completed differently.

Klang Hint #6 on turning Future into a writable thing you can cancel also rises to your objection.

There are in fact many ways the future may complete unexpectedly.

For some reason, the Klang blog says, “Never assume malice.” I always forget if the word for that is epigraph or epitaph.

1 Like

Yes, but you intended to give yourself write permission; you didn’t intend to give everyone write permission.

I’m not sure that this one is easily fixable—but would love to review any PRs which do not adversely affect performance. (This “problem” is manifested in implementations since the introduction of SIP-14)

1 Like
def foo[A](promiseOrFuture: Promise[A] | Future[A]) ...=
  promiseOrFuture match {
     case p: Promise[A] => ...
...

An interesting interaction with union types, but I can think of a meriad of similar cases to this. Matching like this where values can have more than one type of the union always will allow bugs like this.

You could make a similar mistake with collection.Seq[T] | mutable.Buffer[T] (although they are subtypes) or java.concurrent.Flow.Publisher[T] | java.concurrent.Flow.Subscriber[T] because Processor[T, R] implements both.

1 Like

Indeed, it’s the generalisation that Oscar made:

1 Like

I think we can generalize things further as: if you have a union type A | B where you cannot guarantee that A and B are disjoint, you’re doing something wrong.

It’s not an “interaction” with union types. It’s just the union types (and how they are used in the original example). Promise is not doing anything wrong, IMO.

2 Likes

Interesting. What are possible ways to guarantee that, and can the compiler enforce this?

Can I somehow express in my code that I’m assuming two types to be disjoint such that the compiler will warn or error if that assumption may not be true?

1 Like

No, that’s not possible in general. The Scala type system does not have any feature to guarantee disjointness, in general. It is your job to do so if you wish to use union types. If you can’t guarantee disjointness yourself, as a human, don’t use union types.

An easy example where one can guarantee disjointness is when A and B are both unrelated classes.

Another possibility is if A is unconstrained, but B is a known internal (private) class. If instances of the internal class cannot leak to the outside world, then you have a guarantee that a user-provided A cannot be a B. That is actually a useful scenario, but the compiler cannot prove that without some very advanced escape analysis.

1 Like

Separately, it is also OK to talk about A | B when you don’t require that they be disjoint, but only if any treatment that you apply to an A is also valid if you apply on a B, and conversely. So there are cases where a non-disjoint union can be correct—which means the compiler can’t outright reject non-disjoint union even if it were smart enough to prove them—, although some extra care is also required in that situation.

1 Like

That sounds like a major problem, then, doesn’t it?

Two library types A and B may be unrelated classes now, but who will even notice if in a later version of that library A and B are turned into traits?

Probably many people will simply assume two types are unrelated until there is some sign to the contrary.

1 Like

MiMa will notice. This was never a compatible change, source or binary, backward or forward.

What can I say? Then “many people” shouldn’t use union types?

Let’s say I have this code in my app:

val a: A = A(“yo”)

println(a.string)

where A is defined in some library a4s, version 1, like this:

case class A(string: String)

Then, version 2 of a4s comes out, and now it is:

trait A {

** def string: Unit**

}

case class AImpl(string: String) extends A

object A {

** def apply(string: String): A = AImpl(string)**

}

I replace version 1 with version 2 in my build definition and I’m happy to see that it still compiles and the unit tests still pass.

I don’t know that much about compatibility, but the API has not changed, so I thought it is compatible, is it not? Regardless, how I would notice the change?

I don’t understand what you’re getting at. If the library makes breaking changes, your code might break. Not all breaking changes imply compile errors for broken code. Sometimes (often) they imply behavioral changes. The present issue has nothing to do with it; it doesn’t make things any different than before.

There are so many ways to break code, but hidden type overlap makes is surprisingly easy to break code and is quite hard to detect.

2 Likes

I’m with @curoli. I would want to have a way to future-proof all my union types, to validate that none of the element types overlap.

Forget about the traits.

Today I can manually validate that libfoo's foo.Foo and libbar's bar.Bar don’t overlap and that, therefore, my Foo | Bar union type is safe. But tomorrow libbar v1.1 is released and it has a brand new dependency on libfoo and it made Foo the parent of Bar. Now I’m hoping that I matched on Foo cases before Bar cases, because all of a sudden that ordering really matters…

1 Like

Isn’t that just better solved with a general rule of “Don’t treat pattern matching as casting unknown types”? If you’re casting an instance and don’t know it’s concrete type, you will get surprises in tons of ways. Don’t even need union types for those surprises either. I’m confused why union types would get such a special treatment.

1 Like

If you insist on refusing my opinion that “if a library breaks its API, then it can break code”, then you have to only use | in the alternative case that I mentioned earlier:

(Or with one of the branches being a private type that you can guarantee will be disjoint from any other type, irrespective of what other libraries do.)

1 Like

This seems extremely reasonable to me. It’s just the Liskov Substitution Principle: if an operation is valid on A, the operation is valid on an A that also extends B, because extending B doesn’t make it any less of an A. Maybe there’s an operation that is valid on it as a B, but that also doesn’t remove the fact that there’s an operation that’s valid on it as a A.

If you want to live with OO-style open class hierarchies, you have to live with OO-style principles like LSP, which are entirely self-consistent once you buy into the OO style of doing things. If you try and treat OO-style open class hierarchies like FP-style closed type hierarchies, you’re going to have a bad time, but that’s probably unavoidable. If you want FP-style closed type hierarchies, use sealed traits and sealed case classes, or the typeclass pattern if you want extensibility.

6 Likes