-
Notifications
You must be signed in to change notification settings - Fork 10
Turns the standard AST JNumber
into a safe constructor
#30
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
Conversation
JNumber
into a safe constructor, #28 and #22JNumber
into a safe constructor
Codecov Report
@@ Coverage Diff @@
## master #30 +/- ##
==========================================
+ Coverage 44.56% 44.68% +0.12%
==========================================
Files 5 5
Lines 662 687 +25
Branches 128 133 +5
==========================================
+ Hits 295 307 +12
- Misses 367 380 +13
Continue to review full report at Codecov.
|
throw new NumberFormatException(value) | ||
} | ||
final class JNumber(private[ast] val underlying: String) extends JValue { | ||
@inline val value: String = underlying |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the purpose of having underlying
as well as this value
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To access the raw string value (you can't access value
because it's private and for large numbers you may want to access the String
value)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if only for that reason then a def would make more sense perhaps (not to grow the memory footprint of the object)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah that's why I used@inline
, or does this not work?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If it's a val
the value has to be stored somewhere. @inline
means the content of the method (in this case accessing the field) should be inlined.
It's also generally not a good idea to use @inline
on a public API method, since it makes it harder to change the implementation while maintaining backwards compatibility.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, as Greg said. @inline
is bad for bin compat, does not change field semantics, may only do so for the method, and here we just want the method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To access the raw string value (you can't access value because it's private and for large numbers you may want to access the String value).
I don't see the benefit of adding a second value
member.
Why not simply call the constructor parameter value
and make it a public field?
Why do you prefer the "manual" case class over the actual |
else | ||
None | ||
|
||
def unapply(arg: JNumber): Option[String] = Some(arg.underlying) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could be improved via something like this, i.e. it could be done in a way that avoids the allocation here.
Not saying that this should be done, just pointing out the opportunity.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will have a look at this tomorrow
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From an end-user standpoint I'm not a fan of having a JNumber.unapply
that provides a string. Maybe we should just remove the unapply altogether? What is a user supposed to do when getting this string? Try to parse it as a double? as a BigDecimal
? It would be nice if the type system could help out more here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The unapply
for JNumber is there for these reasons
- Consistency in the AST (everything is a case class, containing a single
value
with an unapply) - The string can be directly converted to the number you want (i.e.
toDouble
,toInt
) since its always going to be a valid number. This may be improved with Remove the .to[X] converters #17 (comment)
@sirthias The reason why I used a manual class is because of Scale 2.10.x support. Also there isn't any real difference for the user and we don't have to rely on compiler flags (safer route) |
if (!value.matches(jNumberRegex)) { | ||
throw new NumberFormatException(value) | ||
} | ||
final class JNumber(private[ast] val underlying: String) extends JValue { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't this constructor be private?
That said, it would be preferable to provide different implementations of JNumber
for each of the cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1
private
should be the default.
Making it public should require respective argumentative backup.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is private, just for the package ast
. This is done to save performance as it allows both the standard ast and unsafe
to access the private
constructor to save on performance in some cases (bypassing the regex check when not needed). Its still private to anything that is outside of the scalaJSON package.
If we end up removing the unsafe
AST or it gets changed, I will definitely change it back to private.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
currently the constructor is public
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think (barring the manual case class discussion) this would be the preferred solution:
final class JNumber private[ast](val value: String) extends JValue {
...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. JNumber private[ast] (...)
is the way to go.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah I misunderstood you guys, fixing it now
else | ||
None | ||
|
||
def unapply(arg: JNumber): Option[String] = Some(arg.underlying) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From an end-user standpoint I'm not a fan of having a JNumber.unapply
that provides a string. Maybe we should just remove the unapply altogether? What is a user supposed to do when getting this string? Try to parse it as a double? as a BigDecimal
? It would be nice if the type system could help out more here.
throw new NumberFormatException(value) | ||
} | ||
final class JNumber(private[ast] val underlying: String) extends JValue { | ||
@inline val value: String = underlying |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If it's a val
the value has to be stored somewhere. @inline
means the content of the method (in this case accessing the field) should be inlined.
It's also generally not a good idea to use @inline
on a public API method, since it makes it harder to change the implementation while maintaining backwards compatibility.
Ok, so it comes down to either supporting Scala 2.10 or going for the standard case class.
So, overall I think we need to have very good reasons to not go for the standard case class. |
http4s, apparently they are going to be stuck on 2.10 for a while. Also circe (due to http4s and cats)
Afaik, the asymmetry is only in the source and not how end users end up using the
This is probably the most convincing argument, but I am not completely aware of these optimizations. Also I think you can simulate the changes for
This is true
This is a documentation issue
Unfortunately its very significant (http4s/circe), but there are alternatives
Also with these compiler flags that are needed for |
To be honest, even though not great, I'd prefer the first of your listed alternatives over the manual case class way. Paying forever into the future with the drawbacks just for Scala 2.10 support now doesn't appear like a good tradeoff to me. So how about splitting out a
Yes, that is also my understanding. This flag only affects the compiler frontend. |
} | ||
|
||
def fromString(value: String): Option[JNumber] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With Scala 2.10 out of the way we should be able to simply name this method apply
as well, like all the others...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am thinking of leaving in the fromString
method for all versions and aliasing it to apply
for Scala 2.10.x and Scala 2.11.x, that way for a codebase that needs to targets all Scala versions you can use fromString
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am thinking of leaving in the fromString
method for all versions and aliasing it to apply
for Scala 2.10.x and Scala 2.11.x, that way for a codebase that needs to targets all Scala versions you can use fromString
@sirthias Sounds like a separate branch for 2.10.x support is the way, will work on it tonight |
@@ -95,7 +95,7 @@ object JNumber { | |||
* | |||
* @author Matthew de Detrich | |||
*/ | |||
final class JNumber(private[ast] val underlying: String) extends JValue { | |||
final class JNumber private[ast] (val underlying: String) extends JValue { | |||
@inline def value: String = underlying |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO, with the constructor parameter being a public field, this construct is now completely superfluous.
Or is there some benefit that I am not seeing?
Scala 2.11.x + uses a proper case class Now using sbt-cross-project Moved private[ast] to the whole constructor Removed underlying (not needed with move of private[ast])
@sirthias @ktoso @Ichoran New PR is up. Lot of stuff has changed, have removed @sirthias I have provided an apply for |
*/ | ||
// Due to a restriction in Scala 2.10, we can't use a proper case class along with | ||
// a private constructor, so we have to simulate a case class with a normal class | ||
final class JNumber private[ast] (val value: String) extends JValue { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about a copy
method?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good pickup, I will add a copy
method
* @author Matthew de Detrich | ||
*/ | ||
// Due to a restriction in Scala 2.10, we can't use a proper case class along with | ||
// a private constructor, so we have to simulate a case class with a normal class |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As a side note and for the record: This is not correct. Even in Scala 2.10 case classes can have a private constructor. The issue is that we cannot override/replace the default apply
method generated by the compiler even when the constructor itself is marked private
, which kind of defeats the purpose of marking the constructor private
in the first place.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Worded it wrongly, will update the documentation now.
* @author Rex Kerr | ||
* @see https://github.com/Ichoran/kse/blob/master/src/main/scala/jsonal/Jast.scala#L683-L917 | ||
*/ | ||
private[ast] def numericStringEquals(a: String, b: String): Boolean = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is crazy!
What's the purpose of this special numericStringEquals
?
I think what is missing from the whole effort is a clear statement as to what semantic level the scalajson AST is supposed to target.
The decision of having JNumber
wrap a String
leads one to believe that the AST is targeting the lowest, closest-to-the-source, level, which can be a completely valid approach.
But then I'd expect that JNumber("0") != JNumber("-0")
!
This here appears to try to turn JNumber
into a hybrid: Represent the lowest semantic level but try to also uphold some kind of higher-level guarantees. IMHO this is not only confusing but also a setup for weird and hard to understand effects down the road.
We should make a clear decision as to what we want the AST to represent and then model exactly that, not more and not less, with a consistent and no-magic approach.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
numericStringEquals
is designed to test for actual number equality however there is clearly a bug in it (@Ichoran are you able to help here)?
This is something that people asked for, i.e. they expect that JNumber
does proper number equality. So yes its true that this is a weird hybrid, but the reasoning for using String
in the first place is because no unbounded real number exists in Scala (else we would be using that which would implement its own proper number equality)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
People ask for (and will ask for) many things.
The hard part of this effort is to know which requests to fulfill and which to deny.
Fulfilling all requests is a sure path to disaster.
IMHO there are two choices:
- Make
JNumber
represent a parsed number, i.e. lift the level of abstraction from the character level to some higher number-representing data type. - Make
JNumber
a character representation of a number, i.e. without actually raising the level of abstraction. Rather: Merely group a bunch of characters and mark them as representing a number.
From reading the source one gets the impression that scalajson is doing the latter and not the former. The former is possible but a bit more involved. Circe goes down this route and is a good example of how it can be done. Unfortunately this requires the introduction of another number type (BiggerDecimal
in circe's case) since there is no readily available one that can properly represent all JSON numbers.
My impression was that scalajson chose to go down the "stay low-level" path exactly to circumvent these kinds of issues. The consequence would have to be that JNumber("0") != JNumber("-0")
, which is not only expected from the type setup, it is also consistent with the semantic approach.
I really see no value in going for a strange semantic mix. How would that be useful?
When would I want to rely on character-level equality when I really want some kind of higher-level equality?
The question of whether it's even feasible to properly do equality on arbitrary JSON numbers is a second issue. Is there a proper testsuite for the currently implemented "magic" equality implementation?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will wait for @Ichoran to chime in, I think there was a reason behind it. To be frank, I have zero issues in removing numericStringEquals
is it means less work for me to maintain.
07bc5e9
to
1baab54
Compare
@dwijnand @eed3si9n @ktoso @sirthias @Ichoran @gmethvin I am going to release @sirthias I have created an issue at #36 for your concern regarding Also the name based extractor optimizations no longer apply, named based extractors only came into Scala |
@sirthias @Ichoran @ktoso @fommil This PR disallows
JNumber
to be called directly via the constructor as aString
(there is afromString
method which returns anOption[JNumber]
with the regex check).The regex check has also been removed from all constructors which are guaranteed to be a number, and since
JNumber
is afinal class
, all of the boilerplate methods to make it treated as acase class
has been defined (unapply
/canEqual
/productArity
/productElement
).Note that this was only done to the standard AST and not the unsafe one (not sure if it should also be done for unsafe). Note that when combined with #17 (comment) its possible to do some include a bit flag field within
JNumber
which can track if it gets created as a long/int/double/float so we can make efficient .to converters for different number types.Let me know if this is looking good, and I can merge/release to M4. I think the only contentious point would be whether these changes make sense in the unsafe AST