scalacheck icon indicating copy to clipboard operation
scalacheck copied to clipboard

Defer evaluation of `Prop` labels

Open mrdziuban opened this issue 2 years ago • 11 comments

I've discovered that a bottleneck in my tests is the generation of labels to be displayed if the test fails. I'm using pprint like this:

myProp :| s"expected: ${pprinter(...)}, actual: ${pprinter(...)}"

and I see in a stack dump that pprinter is being called even when the test succeeds.

This does a few things to defer evaluation:

  • Adds new Prop.label, Prop.:|, and Prop.|: methods that take a by-name parameter
  • Marks the old Prop.label, Prop.:|, and Prop.|: with by-value parameters as private[scalacheck]
  • Only adds the label to Prop.Result.labels if the result's status is Failed or Exception -- this ensures the by-name parameter isn't evaluated when the test succeeds

mrdziuban avatar Jul 05 '23 17:07 mrdziuban

Also of note, the by-name parameter of Prop.|: won't take effect on scala 2.12 because of https://github.com/scala/bug/issues/1980 (fixed in scala 2.13). The compiler reports:

[warn] /Users/matt/scalacheck/core/shared/src/main/scala/org/scalacheck/Prop.scala:158:7: by-name parameters will be evaluated eagerly when called as a right-associative infix operator. For more details, see scala/bug#1980.
[warn]   def |:(l: => String) = label(l)

mrdziuban avatar Jul 05 '23 17:07 mrdziuban

Made some changes here to ensure that this is binary compatible, thank you @armanbilge!

mrdziuban avatar Jul 05 '23 19:07 mrdziuban

@satorg is there any chance you could review this? I have a hack in place in my codebase to do the same but would love to bring the change to all scalacheck users and resolve #969. Thanks in advance!

mrdziuban avatar Oct 27 '23 11:10 mrdziuban

@mrdziuban thank you for the ping. To be honest, I forgot about your PR a little bit, sorry about that.

I'm trying to wrap my head around the proposed changes, so my judgements may or may not be correct, feel free to argue please – I am totally open to change my mind.

  • Adds new Prop.label, Prop.:|, and Prop.|: methods that take a by-name parameter
  • Marks the old Prop.label, Prop.:|, and Prop.|: with by-value parameters as private[scalacheck]

That assumes – we're changing the behavior of the existing APIs, aren't we? I believe the initial intent for labels was simply to provide constant-string values – this is why they were made just values.

I am pretty sure there are a lot of tests around that do exactly that, i.e. just use constant strings as a clue. Since by-name parameters come at the price of an additional allocation, then some of such tests (that happen to use labels a lot) can start experiencing unwanted effects.

Moreover, there are also labels available for Gen and those are not deferred either. Therefore to me it seems that making labels deferred for Prop but keeping them as values for Gen might be quite confusing.

Wrapping up, perhaps (maybe) instead of replacing the existing behavior silently, it would make sense to provide an alternative APIs for lazy labels. I.e. similar to List vs LazyList in Scala: add lazyLabel and keep the existing label intact. Also I think we could adopt the idea of # symbol in shortcuts similar to the Scala library: :| and #:| for label and lazyLabel correspondingly.

satorg avatar Oct 27 '23 18:10 satorg

@satorg no problem, thanks for taking a look!

Yes, this does change the behavior of existing APIs, but in a way that I think will benefit more users than it will hurt.

the initial intent for labels was simply to provide constant-string values

Perhaps, but in my codebase (and I would guess a number of others) I use the labels to provide debug info so someone looking at a test failure has somewhere to start.

making labels deferred for Prop but keeping them as values for Gen might be quite confusing

Interesting point. My thinking was that the different behavior made sense because a Gen is always used in a test, whereas the Prop's label is only used if the Prop fails. When does the label attached to a Gen appear to a user?

perhaps (maybe) instead of replacing the existing behavior silently, it would make sense to provide an alternative APIs for lazy labels

I'm not opposed to this and would be willing to implement it if we decide it's the best path forward, I just might suggest using :#| and |#: to preserve the operator associativity

mrdziuban avatar Oct 30 '23 22:10 mrdziuban

So, is this going to be integrated to master in some form? What is the current obstacle to do so?

noresttherein avatar Dec 18 '23 02:12 noresttherein

@satorg gentle ping, do you have any thoughts on this?

mrdziuban avatar Jan 09 '24 14:01 mrdziuban

@mrdziuban ,

My thinking was that the different behavior made sense because a Gen is always used in a test, whereas the Prop's label is only used if the Prop fails. When does the label attached to a Gen appear to a user?

To my best knowledge, pretty much the same thing – when a test fails:

import org.scalacheck._

object LabelsTestApp extends Properties("LabelsTestApp") {
  val myIntGen: Gen[Int] = Arbitrary.arbitrary[Int].label("myIntGen")

  def myProp(n: Int): Prop = (n % 2) == 1

  property("check_it_out") =
    Prop.forAllNoShrink(Arbitrary.arbitrary[Int], myIntGen) { (n1: Int, n2: Int) =>
      myProp(n1).label("default-gen") || myProp(n2).label("custom-gen")
    }
}

which results to:

failing seed for LabelsTestApp.check_it_out is zs1V5_ibhRwBRBjoYrGG-0-Y-S9DKSrbbuSHaZqQgYF=
! LabelsTestApp.check_it_out: Falsified after 7 passed tests.
> Labels of failing property: 
default-gen
custom-gen
> ARG_0: -628739385
> myIntGen: -1
Found 1 failing properties.

satorg avatar Jan 10 '24 06:01 satorg

My initial idea was to keep the existing functionality intact while add a new one as necessary (i.e. via lazyLabel or something). However, I might not be the best person to justify that. To be honest, I have never used labels with Prop, although I do use labels with Gen sometimes (basically when there are a bunch of custom generators in my code and I want to make it clearer which of those provides which data).

My assumption was that adding new functionality without changing the existing one has its advantages:

  • no need to move tests around (so if it is still desired, it can be addressed separately);
  • no unexpected changes neither in behavior nor memory footprint for existing tests;

But perhaps the existing functionality in regards to labels can be considered so bad that it might be worth changing anyway...

satorg avatar Jan 10 '24 06:01 satorg

The linked PR proposes =|= for this purpose. That gives the op the desirable precedence, since one side is likely to be a boolean expression a && b.

I'd have to be shown that there is a performance consequence. But I haven't been researching; I just needed to do some scalachecking today.

There may be future hiccups or restrictions with right-associative operators in Scala 3. It's not a terrible idea to avoid them.

som-snytt avatar Mar 16 '24 05:03 som-snytt

I'm cool with =|=, I definitely see the binary compatibility benefit of defining a new operator instead of modifying the existing one. Just added some comments to your PR @som-snytt, and feel free to close this if that one seems preferable @satorg.

mrdziuban avatar Mar 22 '24 13:03 mrdziuban