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

Add Defer instances for Decoder and Encoder #2123

Open
wants to merge 14 commits into
base: series/0.14.x
Choose a base branch
from

Conversation

morgen-peschke
Copy link

Implements #2122

@@ -763,4 +764,6 @@ class DecoderSuite extends CirceMunitSuite with LargeNumberDecoderTestsMunit {
assert(result.isInvalid)
assertEquals(result.swap.toOption.map(_.size), Some(2))
}

checkAll("Defer[Decoder]", DeferTests[Decoder].defer[MiniInt])
Copy link
Member

Choose a reason for hiding this comment

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

Can you add some property tests for recursive datastructures like what was used in the example? Just to verify further the stack safety and serve as an example for users on how to user defer?

Copy link
Author

Choose a reason for hiding this comment

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

I added non-property test, as generating recursive data structures are kind of annoying to write Gens for.

Not impossible though, so if you feel strongly that they need to be property tests, I can take a pass at converting them.

Copy link
Member

Choose a reason for hiding this comment

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

I think we need this test to have some depth to it. We need to have the test ensure the stack safety of the implementation of defer. Its probably fine to use List as the type the way you did, but using a generated list of some significant length should do the trick.

Copy link
Author

Choose a reason for hiding this comment

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

Clarification question: are you concerned about the stack safety of the Defer instance, or the Decoder it's used to write?

The former is tested by the Defer laws:

  + Defer[Decoder]: defer.defer Identity 0.156s
  + Defer[Decoder]: defer.defer does not evaluate 0.001s
  + Defer[Decoder]: defer.defer is stack safe 0.319s
  + Defer[Decoder]: defer.defer matches fix 0.429s

The later I think may be out of our control.

modules/core/shared/src/main/scala/io/circe/Decoder.scala Outdated Show resolved Hide resolved
@zmccoy
Copy link
Member

zmccoy commented Apr 6, 2023

Could we add some documentation as well to the docs for this? You could easily transform what you have in the accommodating issue and it would be great for discoverability of this.

- Cache the resolution loop so we don't have to do it every time the instance is called.
- Preserve short-circuit decoding behavior when not accumulating errors
@zarthross
Copy link
Member

@morgen-peschke Thanks for adding the tests. It looks like this build failed MIMA for some reason, do you mind taking a look?

Comment on lines 1464 to 1466
implicit final val decoderInstances
: SemigroupK[Decoder] with MonadError[Decoder, DecodingFailure] with Defer[Decoder] =
new SemigroupK[Decoder] with MonadError[Decoder, DecodingFailure] with Defer[Decoder] {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you'll need to do something like this to fix bincompat

Suggested change
implicit final val decoderInstances
: SemigroupK[Decoder] with MonadError[Decoder, DecodingFailure] with Defer[Decoder] =
new SemigroupK[Decoder] with MonadError[Decoder, DecodingFailure] with Defer[Decoder] {
@deprecated
def decoderInstances: MonadError[Decoder, DecodingFailure] = decoderInstancesBinCompat
implicit final val decoderInstancesBinCompat =
: SemigroupK[Decoder] with MonadError[Decoder, DecodingFailure] with Defer[Decoder] =
new SemigroupK[Decoder] with MonadError[Decoder, DecodingFailure] with Defer[Decoder] {

Copy link
Member

Choose a reason for hiding this comment

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

Alternatively, we could just make a separate val for the Defer couldn't we? Defer has no parents and no children so there shouldn't be any real need for it to be part of decoderInstances right?

Copy link
Contributor

Choose a reason for hiding this comment

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

Sure, good point!

Copy link
Member

Choose a reason for hiding this comment

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

Also, does the order matter on those with clauses? Maybe moving Defer[Decoder] to before MonadError[Decoder, DecodingFailure] instead of after?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure. But I do know that Scala 2 and Scala 3 have different algorithms for this, so it may be impossible to reconcile in a way to make all versions happy. We had a similar issue in Cats (actually we accidentally broke bincompat on Scala 3 since weren't checking it back then ... )

Copy link
Contributor

Choose a reason for hiding this comment

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

@hamnis
Copy link
Collaborator

hamnis commented Apr 17, 2024

would you mind fixing the merge conflics here?

@hamnis
Copy link
Collaborator

hamnis commented May 14, 2024

failure is genuine: https://github.com/circe/circe/actions/runs/9078292566/job/24945019131?pr=2123#step:13:2785

implicit def uglyListDecoder[A: Decoder]: Decoder[List[A]] =
Decoder.recursive[List[A]] { implicit recurse =>
Decoder.instance { c =>
(c.downField("car").as[A], c.downField("cdr").as[Option[List[A]]]).mapN(_ :: _.getOrElse(Nil))

Choose a reason for hiding this comment

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

I don't think this is stack safe, and it looks like the tests fail.

So, recurse is doing to be used at c.downField("cdr").as[Option[List[A]]] but that's nested inside of a mapN. While nesteding Defer is stack safe in the construction you have here, composing with map2, which is what is happening here, isn't. Something like: lazy val d = map2(a, defer(d))(_ :: _), map2 would need to somehow unroll that loop. It's not easy to see how that could happen.

So, I think the stack depth will be the same depth as the object graph.

cats-parse has the same issue on parsing (which is the same basic typeclass as Decoder).

Choose a reason for hiding this comment

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

If you ask me, we should merely add a comment to recursive and remove this test. Many structures have depth = log(size), so they are perfectly safe to parse with this. If you have a linear depth object (such as a linked list), this isn't suitable if the lists are big.

Copy link
Author

Choose a reason for hiding this comment

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

Something like this?

Note: while a Decoder written using Decoder.recursive can prevent unneeded creation of Decoder instances when recursing, the resulting Decoder cannot be guaranteed to be stack-safe.

Choose a reason for hiding this comment

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

yeah, something like that seems right.

import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME

Choose a reason for hiding this comment

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

personally, I don't like these import reformats. I think grouped imports make it easier to see the package level dependencies and I would rather use { } to the maximum vs the minimum.

Copy link
Author

Choose a reason for hiding this comment

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

I don't have strong opinions on it, other than "let the tooling handle it" 😅

In this case, it was the result of running scalafixAll and scalafmtAll (then reverting the changes to the files I hadn't touched).

Copy link
Author

Choose a reason for hiding this comment

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

I can revert the changes manually if you find it particularly objectionable, but it might be worth revisiting the formatting config if that is the case 🤷🏻

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, in my experience, unfolded imports tend to trigger less conflicts on merges and rebases. I guess this could one of the reasons why it was made a default behavior for the OrganizeImports rule in scalafix.

Copy link
Author

Choose a reason for hiding this comment

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

I needed to re-run the CI, so I've rolled back the import reformat

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants