Inconsistent encoding of Double depending on container structure
This is closely related to #546. Initially I thought there was some oddness specific to derived instances, but this comment pointed me toward a nice trivial example of the underlying cause:
λ. import Data.Aeson
λ. encode 0.0
"0.0"
λ. encode $ toJSON 0.0
"0"
And its consequence:
λ. :set -XDeriveGeneric -XDeriveAnyClass -XDerivingStrategies -XGeneralizedNewtypeDeriving
λ. newtype Foo = Foo Double deriving Generic
λ. deriving newtype instance ToJSON Foo
λ. encode $ Foo 0.0
"0.0"
λ. newtype Bar = Bar Double deriving Generic
λ. deriving anyclass instance ToJSON Bar
λ. encode $ Bar 0.0
"0"
λ. data Baz = Baz Double deriving (Generic, ToJSON)
λ. encode $ Baz 0.0
"0"
λ. data Qux = Qux Foo deriving (Generic, ToJSON)
λ. encode $ Qux (Foo 0.0)
"0"
That last one is especially irksome: checking the newtype encoding doesn't reflect what the full structure will look like. (And of course, that's exactly the context where I ran into this.)
This issue is specifically about the inconsistency. It is surprising and undesirable that Doubles render differently depending on context. Which way they should be rendered probably belongs to #546. However, I should add that at present it's not a clear-cut matter of deriving newtype being the odd one out:
λ. encode (0.0, "whoops")
"[0.0,\"whoops\"]"
Quick follow-up:
λ. data Quux = Quux Foo deriving Generic
λ. instance ToJSON Quux where toEncoding = genericToEncoding defaultOptions
λ. encode $ Quux (Foo 0.0)
"0.0"
So that's at least a convenient work-around to get consistent output.
Something is wrong with Generic ToJSON instance:
λ *.Aeson G.Generics> :set -XDerivingStrategies -XDeriveAnyClass -XDeriveGeneric
λ *.Aeson G.Generics> data D = D Double deriving stock (Generic) deriving anyclass (ToJSON)
λ *.Aeson G.Generics> toEncoding (D 0.0)
"0"
λ *.Aeson G.Generics> genericToEncoding defaultOptions (D 0.0)
"0.0"
And the reason is the way ToJSON is defined:
class ToJSON a where
-- | Convert a Haskell value to a JSON-friendly intermediate type.
toJSON :: a -> Value
default toJSON :: (Generic a, GToJSON Value Zero (Rep a)) => a -> Value
toJSON = genericToJSON defaultOptions
toEncoding :: a -> Encoding
toEncoding = E.value . toJSON
The default definition for toEncoding use toJSON, and not genericToEncoding. This is a choice, so only toJSON could be defined when writing instances manually. The documentation even mentions that:
-- 2. The choice of defaults allows a smooth transition for existing users:
-- Existing instances that do not define 'toEncoding' still
-- compile and have the correct semantics. This is ensured by making
-- the default implementation of 'toEncoding' use 'toJSON'. This produces
-- correct results, but since it performs an intermediate conversion to a
-- 'Value', it will be less efficient than directly emitting an 'Encoding'.
-- (this also means that specifying nothing more than
-- @instance ToJSON Coord@ would be sufficient as a generically decoding
-- instance, but there probably exists no good reason to not specify
-- 'toEncoding' in new instances.)
The subtle difference between encoding through toJSON and toEncoding may be highlighted. (It's somewhat known that they produced different JSON text, and the difference might or might not be important for you).
As a side note, for GHC-8.6 we could/should provide a DerivingVia helpers, which would solve the problem "what to have as the default". There're example worked out in the paper, IIRC.