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

Document protocol implementations #220

Merged
merged 1 commit into from
Feb 11, 2025
Merged

Document protocol implementations #220

merged 1 commit into from
Feb 11, 2025

Conversation

wojtekmach
Copy link
Collaborator

Also, improve error message on Decimal.to_float for inf/nan.

Copy link

@azizk azizk left a comment

Choose a reason for hiding this comment

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

Thanks for creating this PR.

I really like the explanation that is provided in ActiveSupport in the Rails framework. I'm sure developers unfamiliar with the issue would appreciate an explanation like that. 👍


iex> encoder = fn
...> %Decimal{} = decimal, _encoder ->
...> decimal |> Decimal.to_float() |> :json.encode_float()
Copy link

Choose a reason for hiding this comment

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

I know we can't directly use Decimal.to_string/1 but the precision loss via float shouldn't be assumed acceptable. If we had a Decimal.finite?/1 or Decimal.number?/1 function this example could be more accurate:

if Decimal.number?(decimal), do: Decimal.to_string(decimal)
# Or:
true = Decimal.number?(decimal)
Decimal.to_string(decimal)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

but the precision loss via float shouldn't be assumed acceptable

Not sure I follow. I believe this is Eric's and mine point all along.

In any case, sure, we could add something like the following to the example encoder.

if Decimal.inf?(decimal) or Decimal.nan?(decimal) do
  raise ArgumentError, "#{inspect(decimal)} cannot be encoded to JSON"
end

However, Decimal.to_string("1.00") #=> "1.00" and that is not the result we want.

Copy link

@azizk azizk Feb 11, 2025

Choose a reason for hiding this comment

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

Okay I see what you mean by the 1.00 example. But let's say you want to encode Decimal.div(1, 3). You will lose quite a few decimal places when you convert to a 64-bit float first...

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I am misunderstanding something or we are talking past each other. Yes, we are losing precision, that's why we want to use strings not floats!

iex> Decimal.div(1, 3) |> Decimal.to_string() |> JSON.decode!() |> dbg
Decimal.div(1, 3) #=> Decimal.new("0.3333333333333333333333333333")
|> Decimal.to_string() #=> "0.3333333333333333333333333333"
|> JSON.decode!() #=> 0.3333333333333333

Same with Deno:

$ deno
> JSON.parse("0.3333333333333333333333333333")
0.3333333333333333
>

I don't see the point of encoding to a higher precision something that is expected to be a float. My understanding is decoders convert to float and lose that precision anyway.

Copy link

Choose a reason for hiding this comment

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

I think we're basically talking about the same thing, but your intention is now clearer to me.

Since the default (protocol impl) behaviour is to encode a Decimal as a string, isn't the point of overriding the protocol encoder in the example to show how to encode it as a JSON number instead? If the client you intend to send the JSON to, cannot handle JSON floats properly, it would of course not make any sense to do that. So we normally would do that when the API requires it or we want to store floats in a database JSON column.

If you return a string like "0.3333333333333333333333333333" in the encoder function it will become a JSON float. No problem there unless you have to parse it with JS. But even in Elixir you will get an inaccurate float, unless you provide the :float option and construct a Decimal using Decimal.new/1.

I hope I could clear the confusion. :)

Copy link
Owner

Choose a reason for hiding this comment

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

Do you have examples on JSON implementations that can decode literal JSON floats lossless? My understanding is that most implementations, including Elixir which we are using in the example here, would decode it as lossy IEEE 754 float.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think implementations would decode as floats by default but at least in ruby you can opt-in:

$ irb -r json -r bigdecimal
irb> JSON.parse('0.3333333333333333333333333333')
=> 0.3333333333333333
irb> JSON.parse('0.3333333333333333333333333333', decimal_class: BigDecimal)
=> 0.3333333333333333333333333333e0

but yeah. maybe cd6aa59 is a mistake because I have a feeling it will cause confusion to most people. The purpose of this piece of documentation is not to educate people on subtleties of having or not having IEEE 754 but to show that we can customize encoding. @ericmj feel free to revert cd6aa59, I'm done.

Choose a reason for hiding this comment

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

Json itself is actually quite unaware of floats. It just has arbitrary presision numbers being a text based format. It completely depends on the parser or encoder to deal with how to map between runtime level number values and their json text representation.

Copy link
Owner

Choose a reason for hiding this comment

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

I think what we have now is enough. By default we have a safe arbitrary precision encoding using strings, we also have examples of custom encoding to IEEE 754 floats in the docs, and this example can of course be tweaked to whatever precision encoding you want.

Thank you @wojtekmach for the protocol implementation and documentation improvements.

Copy link

Choose a reason for hiding this comment

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

Thanks @wojtekmach for adjusting the example.

I think the example doesn't need to give a complete education but it should make sense. And the only good reason to override the protocol encoder is the need to do something different than the default. I don't see how the adjusted example could be confusing. I'm sure it's helpful for developers to know that you can output lossless JSON floats if required.

To cover a more general case for overriding protocol encoders, an example could be included in the Elixir documentation for JSON.encode!/2. There isn't one currently.

Do you have examples on JSON implementations that can decode literal JSON floats lossless?

Wojteck already gave an example with Ruby. In the issue #219 referenced by this PR, I mentioned a few other languages that can do lossless decoding and encoding. Afaics, JS is probably the only major language that isn't capable of doing this.

Thanks for the improvements. 👍

Also, improve error message on Decimal.to_float for inf/nan.
@wojtekmach wojtekmach merged commit c9347ed into main Feb 11, 2025
2 checks passed
@wojtekmach wojtekmach deleted the wm-document-encoder branch February 11, 2025 19:46
wojtekmach added a commit that referenced this pull request Feb 11, 2025
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.

4 participants