CSharp · Console
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Runtime.Serialization.Formatters;
using System.IO;
using System.Data;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Reflection;
using System.Collections.ObjectModel;
using System.Buffers.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using NUnit.Framework; // Throws NUnit.Framework.AssertionException
//https://stackoverflow.com/questions/66309150/how-do-i-write-a-float-like-82-0-with-the-0-intact-using-utf8jsonwriter
public class DoubleConverter : JsonConverter<double>
{
// 2^49 is the largest power of 2 with fewer than 15 decimal digits.
// From experimentation casting to decimal does not lose precision for these values.
const double MaxPreciselyRepresentedIntValue = (1L<<49);
public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options)
{
bool written = false;
// For performance check to see that the incoming double is an integer
if ((value % 1) == 0)
{
if (value < MaxPreciselyRepresentedIntValue && value > -MaxPreciselyRepresentedIntValue)
{
writer.WriteNumberValue(0.0m + (decimal)value);
written = true;
}
else
{
// Directly casting these larger values from double to decimal seems to result in precision loss, as noted in https://stackoverflow.com/q/7453900/3744182
// And also: https://docs.microsoft.com/en-us/dotnet/api/system.convert.todecimal?redirectedfrom=MSDN&view=net-5.0#System_Convert_ToDecimal_System_Double_
// > The Decimal value returned by Convert.ToDecimal(Double) contains a maximum of 15 significant digits.
// So if we want the full G17 precision we have to format and parse ourselves.
//
// Utf8Formatter and Utf8Parser should give the best performance for this, but, according to MSFT,
// on frameworks earlier than .NET Core 3.0 Utf8Formatter does not produce roundtrippable strings. For details see
// https://github.com/dotnet/runtime/blob/eb03e0f7bc396736c7ac59cf8f135d7c632860dd/src/libraries/System.Text.Json/src/System/Text/Json/Writer/Utf8JsonWriter.WriteValues.Double.cs#L103
// You may want format to string and parse in earlier frameworks -- or just use JsonDocument on these earlier versions.
Span<byte> utf8bytes = stackalloc byte[32];
if (Utf8Formatter.TryFormat(value, utf8bytes.Slice(0, utf8bytes.Length-2), out var bytesWritten)
&& IsInteger(utf8bytes, bytesWritten))
{
utf8bytes[bytesWritten++] = (byte)'.';
utf8bytes[bytesWritten++] = (byte)'0';
if (Utf8Parser.TryParse(utf8bytes.Slice(0, bytesWritten), out decimal d, out var _))
{
writer.WriteNumberValue(d);
written = true;
}
}
}
}
if (!written)
{
if (double.IsFinite(value))
writer.WriteNumberValue(value);
else
// Utf8JsonWriter does not take into account JsonSerializerOptions.NumberHandling so we have to make a recursive call to serialize
JsonSerializer.Serialize(writer, value, new JsonSerializerOptions { NumberHandling = options.NumberHandling });
}
}
static bool IsInteger(Span<byte> utf8bytes, int bytesWritten)
{
if (bytesWritten <= 0)
return false;
var start = utf8bytes[0] == '-' ? 1 : 0;
for (var i = start; i < bytesWritten; i++)
if (!(utf8bytes[i] >= '0' && utf8bytes[i] <= '9'))
return false;
return start < bytesWritten;
}
public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
// TODO: Handle "NaN", "Infinity", "-Infinity"
reader.GetDouble();
}
class TestClass
{
public static void Test()
{
Console.WriteLine($"Math.Log2(1e15)={Math.Log2(1e15)}\n");
var doubles = new []
{
82.0,
82.1,
-82.0,
7e27,
78e27,
0, 1, 2,
-1, -2,
1 + 1e-14,
1 + 1e-15,
1 + 1e-16,
9999999999999992,
(1L<<52)-3,
(1L<<52)-2,
(1L<<52)-1,
(1L<<52),
(1L<<52)+1,
(1L<<52)+2,
(1L<<52)+3,
(1L<<53)-3,
(1L<<53)-2,
(1L<<53)-1,
(1L<<53),
(1L<<53)+1,
(1L<<53)+2,
(1L<<53)+3,
2L*(1L<<53),
2L*(1L<<56),
(double)decimal.MaxValue,
(double)(0.99m*decimal.MaxValue),
double.Epsilon,
-double.Epsilon,
long.MaxValue,
-long.MaxValue,
double.MinValue,
double.MaxValue,
double.PositiveInfinity,
double.NegativeInfinity,
double.NaN,
};
var powersOf2 = Enumerable.Range(0, 64)
.Select(i => 1L<<i).ToList().SelectMany(l => Enumerable.Range(-25, 25).Select(i => (double)((ulong)l + (ulong)i)));
foreach (var d in doubles.Concat(powersOf2))
{
var defaultOptions = new JsonSerializerOptions
{
WriteIndented = true,
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
};
var options = new JsonSerializerOptions
{
Converters = { new DoubleConverter() },
WriteIndented = true,
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
};
var jsonDefault = JsonSerializer.Serialize(d, defaultOptions);
var json = JsonSerializer.Serialize(d, options);
Console.WriteLine("fixed JSON = {0}, Original JSON = {1}", json, jsonDefault);
// Newtonsoft always appends a .0 for doubles, so assert we are doing the same thing.
Assert.AreEqual(Newtonsoft.Json.JsonConvert.SerializeObject(d), json, $"Json.NET and System.Text.Json returned inconsistent results for {d}");
}
}
}
public class Program
{
public static void Main()
{
Console.WriteLine("Environment version: {0} ({1})", System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription , GetNetCoreVersion());
Console.WriteLine("System.Text.Json version: " + typeof(JsonSerializer).Assembly.FullName);
Console.WriteLine();
try
{
TestClass.Test();
}
catch (Exception ex)
{
Console.WriteLine("Failed with unhandled exception: ");
Console.WriteLine(ex);
throw;
}
}
public static string GetNetCoreVersion()
{
//https://techblog.dorogin.com/how-to-detect-net-core-version-in-runtime-ecd65ad695be
//
var assembly = typeof(System.Runtime.GCSettings).GetTypeInfo().Assembly;
var assemblyPath = assembly.Location.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
int netCoreAppIndex = Array.IndexOf(assemblyPath, "Microsoft.NETCore.App");
if (netCoreAppIndex > 0 && netCoreAppIndex < assemblyPath.Length - 2)
return assemblyPath[netCoreAppIndex + 1];
return null;
}
}