Skip to content
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

Float Like A Butterfly: Introduce PresenceBox, TryBox, and ParamTryBox. #1876

Draft
wants to merge 17 commits into
base: main
Choose a base branch
from

Conversation

Shadowfiend
Copy link
Member

@Shadowfiend Shadowfiend commented Jul 7, 2017

These three intermediary types allow APIs and consumers of boxes to indicate
that they expect a particular subset of Box types. This is useful to restrict
types to what is expected, while still being able to interoperate with
APIs designed to interact with many all Box types.

For example, you can declare that a function expectes a PresenceBox to ensure
that interactors guarantee either a Full or Empty is passed. Or, you can
specify that your function returns a TryBox so callers only need to worry about
handling a Full or a Failure. Lastly, ParamTryBox allows indicating that a
given situation will only get a Full or a ParamFailure with a particular error
type.

A lot of tests and additional API conversions are required to fully bring this
vision to fruition, but this is the foundation for that work.

Required before this is ready to merge:

  • Proper typing of all methods in Box.scala
  • Initial battery of specs (with appropriate type annotations)

Of note, the compiler will already properly infer, say, PresenceBox if your
code only produces Full or Empty. It will also infer ParamTryBox[String, Int],
for example, if your code only produces Full or ParamFailure[Int].

Hoping I can get this wrapped up in time for 3.2.0-M2, but we'll see ;)

These three intermediary types allow APIs and consumers of boxes to indicate
that they expect a particular subset of Box types. This is useful to restrict
types to what is expected, while still being able to interoperate with
APIs designed to interact with many all Box types.

For example, you can declare that a function expectes a PresenceBox to ensure
that interactors guarantee either a Full or Empty is passed. Or, you can
specify that your function returns a TryBox so callers only need to worry about
handling a Full or a Failure. Lastly, ParamTryBox allows indicating that a
given situation will only get a Full or a ParamFailure with a particular error
type.

A lot of tests and additional API conversions are required to fully bring this
vision to fruition, but this is the foundation for that work.
@Shadowfiend Shadowfiend added this to the 3.2.0-M1 milestone Jul 7, 2017
This allows us to make sure that we don't lose the specific type of the Box
when doing a pass operation. This was less important when the only supertype
was `Box`, but now that we can have the more specific `PresenceBox`, `TryBox`,
and `ParamTryBox`, preserving those supertypes is more important.
@Bhashit
Copy link
Contributor

Bhashit commented Jul 9, 2017

Ah, looks like I was a bit late in pushing these changes. I did have most of it, was stuck with a serializer and didn't get enough time for that one. Looking great. 🎉

@Shadowfiend
Copy link
Member Author

Ugh, sorry @Bhashit… I kind of did this all in one day, and in my head you were working on the combinators but not the typing changes. Re-reading the original issue, I had no reason to think that, and should have let you know I was going to put pedal to the metal on cranking something out.

In particular, collect and collectFirst will always return a PresenceBox when
invoked on a PresenceBox. This is not the case for the other subtypes.
In particular, cases where each of these can guarantee a subtype of Box have
reimplementations that do so.

PresenceBox also adds a specialization of withFilter, which is used in for
comprehensions, that ensures that typing carries through.
These can now have more specific types.
Also added a blurb to the Box scaladocs regarding the Box subtypes.
@Shadowfiend
Copy link
Member Author

Pushed a bunch more changes here… One note for you @Bhashit particularly for collectFailure in #1864---I had to un-final the collect methods to provide more specific types in PresenceBox for them.

@Bhashit
Copy link
Contributor

Bhashit commented Jul 10, 2017

in my head you were working on the combinators but not the typing changes

No problem at all. I am pretty sure you'd do it better.

I had to un-final the collect methods

Sounds good.

@farmdawgnation
Copy link
Member

Holding off on review here until the tests are passing, but this looks exciting. :)

@farmdawgnation farmdawgnation self-requested a review July 10, 2017 16:53
@farmdawgnation farmdawgnation modified the milestones: 3.2.0-M1, 3.2.0-M2 Jul 22, 2017
@farmdawgnation
Copy link
Member

Tests still are not passing. :(

@Shadowfiend
Copy link
Member Author

Well no changes have been pushed so that seems perfectly reasonable :p

@farmdawgnation
Copy link
Member

BUT I WANT THE FEATURES NOWWWWWWWWWWWWW 👶

Due to the way Scala resolves method overloads with implicit parameters, I
haven't been able to find a good way of defining that a TryBox that contains a
TryBox should flatten to a TryBox, and a PresenceBox that contains a
PresenceBox should flatten to a PresenceBox. Unfortunately Scala seems to
resolve the overload of flatten based solely on class hierarchy and number of
parameters, and then fails the compile if it can't plug the proper implicits
in, rather than using the available implicit evidence to choose between
overloads. Since we rely entirely on implicit evidence to know what we can do
with the contained type of the box, there's no way (that I could find) to
express diverging return types based on the contained type :(

Instead, TryBox and PresenceBox each have their own flattenTry and
flattenPresence methods, respectively, which will only compile if their
contents match the container type. This means the caller has to choose the
appropriate one to call, but at least it ensures you *can* flatten that way if
you really want to.
@Shadowfiend
Copy link
Member Author

Sadly I had to jettison a properly-typed flatten implementation, at least for the time being. Details in the commit, but net-net I couldn't figure out how to get the compiler to play nice on making TryBox[TryBox].flatten=>TryBox and PresenceBox[PresenceBox].flatten=>PresenceBox. For now, that means we have flattenTry on TryBox and flattenPresence on PresenceBox that only compile if both types match and produce the right return type.

I don't like this gotcha one bit :(

Copy link
Member

@farmdawgnation farmdawgnation left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some initial remarks throughout.

I'm a bit concerned about the added conceptual complexity here, but that could just be me not having enough coffee yet. Broadly, I think that Box in its current form is quite easy to understand today. After seeing some of the hoops this code has to jump through to work, I'm wondering whether or not we've made Box harder to understand conceptually and therefore increased the barrier to entry for using it and contributing to it.

This is one of those moments where I sincerely wish scala.util.Try supported arbitrary error messages in its constructor, because if it did I would say "Hey, let's not re-invent the wheel here." :/

In any case, the code itself looks like it's moving in the right direction for a workable implementation. :)

def or[B >: A](alternative: => Full[B]): Full[B]
def or[B >: A](alternative: => PresenceBox[B])(implicit a: DummyImplicit): PresenceBox[B]
def or[B >: A](alternative: => TryBox[B])(implicit a: DummyImplicit, b: DummyImplicit): TryBox[B]
def or[B >: A, E](alternative: => ParamTryBox[B, E])(implicit a: DummyImplicit, b: DummyImplicit, c: DummyImplicit): ParamTryBox[B, E]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm idly wondering if the presence of these DummyImplicits might be a sign we've tried to be a bit too clever by incorporating this into the overall box structure instead of having them as a parallel data type with implicit conversions.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parallel data types with implicit conversions are only an option for a small subset of these methods, because of the fact that some of the methods overlap with Iterable and such, which means if someone imports Box._ (which is not an unreasonable thing to do in a few situations), they'll be unable to get our implicits at a higher specificity than the iterable conversions.

It ain't fun.

That said, these dummy implicits don't have anything to do with the way in which we attach these methods; instead, they are here because we're trying to overload on an parameter type with a call-by-name parameter. Overloading on parameter type is directly supported in the JVM, but by making these by-name we turn them into parameterized Function1s, or something roughly equivalent. That means we're suddenly overloading by the parameter's generic type, which isn't directly supported. Dummy implicits step in to fill this gap.


override def map[B](f: A => B): PresenceBox[B] = Empty
def flatMap[B](f: A => PresenceBox[B]): PresenceBox[B]
override def collect[B](pf: PartialFunction[A, B]): PresenceBox[B] = ???
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should remove the presence of ??? from anything that'll be production code.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll double-check this. I introduced it for a reason, but it may have been an earlier iteration.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting though---neither of the ??? implementations are used in practice, because they are overridden further in all of their subclasses, and dynamic binding ensures it's those implementations, not these, that are executed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'm not concerned about that. More about accident proofing the code. If there is a ??? implementation here, the compiler would say, for example, "Hey this interface is satisfied!" If someone were to somehow break the override of a child implementation, this would become the used implementation.

Whereas, if we don't provide ??? here, breaking the override implementation should break the compile.

*/
sealed trait TryBox[+A] extends Box[A] {
def or[B >: A](alternative: => Full[B]): Full[B]
def or[B >: A](alternative: => PresenceBox[B])(implicit a: DummyImplicit): PresenceBox[B] // *
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rouge comment on this line?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tru.

def or[B >: A](alternative: => TryBox[B])(implicit a: DummyImplicit, b: DummyImplicit): TryBox[B]
def or[B >: A, E2](alternative: => ParamTryBox[B, E2])(implicit a: DummyImplicit, b: DummyImplicit, c: DummyImplicit): ParamTryBox[B, E2]

override def map[B](f: A => B): ParamTryBox[B, E] = ???
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto here with ???

@Shadowfiend
Copy link
Member Author

I'm wondering whether or not we've made Box harder to understand conceptually and therefore increased the barrier to entry for using it and contributing to it.

We've definitely increased complexity here, but a lot of the hoops we jump through here are to make the intuition of this added complexity work as expected.

Specifically, we added PresenceBox, TryBox, and ParamTryBox. These let you specify at a type level that you expect certain subsets of the box hierarchy---a situation we find ourselves in quite frequently, more often than not because we're trying to communicate failure-or-full and there's no way to do that without also having to handle the empty state. From a user perspective, this should be the only complication. The rest of the code in this PR (and there is a good bit of added code) is to ensure that the various Box combinators produce the correct subsets in their type signatures, so that if , say, I want to get a TryBox and I have to do some flatMaps to get there, I don't immediately have to resort to type-casting (because flatMap is aware of what's being flatMapped and can maintain proper typing throughout the operation).

With that said, it definitely increases the barrier to entry for contributing to Box. Personally I can't think of anything else that we would need to add, but of course you can't predict the future. I think from continuous experience that this gets us:

  • A solution to a common problem.
  • A setup to prevent potentially dangerous type conversions in the future. This one specifically is about the implicit box2Option conversion, which today is always potentially lossy. In a world where PresenceBox is a thing, we can imagine giving PresenceBox an implicit conversion to Option but Box would not (obviously we wouldn't do this until a breaking release cycle, though).

I definitely understand the complexity concern here though, so if we collectively think this is too much, I'm happy to leave it on the table.

@farmdawgnation
Copy link
Member

I think the complexity concern here is probably worth it given what we're getting. And I can't think of any way to simplify things significantly.

@Bhashit
Copy link
Contributor

Bhashit commented Sep 11, 2017

FWIW, I'd love having this new hieararchy. The pattern matches with Empty and Failure, even when they are not possible outcomes, always feels impure.

@farmdawgnation farmdawgnation modified the milestones: 3.2.0-M2, 3.2.0-M3 Sep 15, 2017
@Shadowfiend
Copy link
Member Author

Shadowfiend commented Sep 25, 2017

Ok, so slight complication here which we'll need to decide if we're okay with… In a sense, this branch is too effective heh. ?~ now returns TryBox, as it should. This means that code that has a for comprehension where each step uses ?~ to ensure a Failure pops out even in case of Empty, and then pattern-matches on the result, will fail compilation if the developer pattern-matched against Empty (if they didn't, they would have gotten a compiler warning prior to this).

We can change ?~ to return Box instead of TryBox, but we start winking at “what even is the point” territory. The compilation error would also be very easy to fix: the Empty case just needs to be nuked.

An example is this chunk of WithParam:

def render(kids: NodeSeq) : NodeSeq = {
(for {
ctx <- S.session ?~ ("FIX"+"ME: Invalid session")
req <- S.request ?~ ("FIX"+"ME: Invalid request")
} yield {
val name: String = S.attr("name") openOr "main"
val body = ctx.processSurroundAndInclude(PageName.get, kids)
WithParamVar.atomicUpdate(_ + (name -> body))
NodeSeq.Empty
}) match {
case Full(x) => x
case Empty => Comment("FIX"+ "ME: session or request are invalid")
case Failure(msg, _, _) => Comment(msg)
}
}

EDIT to say: I think this is still a big step up, and that given the relatively benign nature of the compilation errors and that they happen at compile time (rather than runtime), this is acceptable.

The existing definition wasn't actually catching for all ParamTryBoxes.
Also add some scaladocs to those two methods.
We do this by dropping the concrete definitions of these two methods in Box,
preferring instead to lean 100% on late binding to resolve the correct concrete
definition of the method based on the concrete type it is being invoked on.
@Shadowfiend
Copy link
Member Author

Ok, wrapped up the specs, made a fix or two, nuked the = ??? definitions, and merged in master with @Bhashit's changes...

I think I consider this PR good to go, provided travis doesn't slap me repeatedly in the face with red ❌es.

@Shadowfiend
Copy link
Member Author

Ohai Scala 2.11. Looks like still some cleanup to be done. Will circle back!

@Shadowfiend
Copy link
Member Author

this is acceptable

Don’t know what I was thinking. Minor releases are API-compatible, by definition. So... that’s awkward heh. Will noodle.

@farmdawgnation farmdawgnation modified the milestones: 3.2.0-M3, 3.2.0-RC1 Nov 17, 2017
@Shadowfiend Shadowfiend modified the milestones: 3.2.0-RC1, Hypothetical 4.0 Dec 7, 2017
@Shadowfiend
Copy link
Member Author

Moved this to hypothetical 4.0 for now… Really bummed about it though :(

@Shadowfiend
Copy link
Member Author

(May be worth discussing if this is worth doing an earlier-than-expected 4.0 release with the community… Perhaps after another couple of minor releases.)

@farmdawgnation
Copy link
Member

Hey there, I'm closing PR as orphaned since it's been more than a year since it's had any updates. Feel free to reopen when you have time to update it!

@Shadowfiend
Copy link
Member Author

Not much to update here, tbh. This PR was left open because it can't land before a 4.0 release; it may have some merge conflicts but I suspect I'd be able to dispense with them when the time comes.

@farmdawgnation
Copy link
Member

Hm, gotcha. I can reopen it.

@farmdawgnation farmdawgnation marked this pull request as draft June 29, 2020 19:15
@farmdawgnation
Copy link
Member

farmdawgnation commented Jun 29, 2020

Also converted it to a Draft so we know it's not an "actually actionable thing" right now

@farmdawgnation farmdawgnation added the Lift5 Features/Changes for Lift 5 label Jul 13, 2024
@farmdawgnation farmdawgnation removed this from the Hypothetical 4.0 milestone Jul 13, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Lift5 Features/Changes for Lift 5
Projects
Status: In progress
Development

Successfully merging this pull request may close these issues.

3 participants