c# SqlDecimal 在小数乘法中翻转符号

c# SqlDecimal flipping sign in multiplication of small numbers

看来我运行正在使用 'my most favorite datatype' SqlDecimal 进入 more woes。 我想知道这是否应该被视为一个错误。

当我在 SQL 中乘以两个小数时,我得到了预期的结果。当我通过 SQLCLR 函数 运行 相同的数字时,结果令人惊讶。

c#代码:

using System.Data.SqlTypes;
using Microsoft.SqlServer.Server;

namespace TestMultiplySQLDecimal
{
    public static class Multiplier
    {
        [SqlFunction(DataAccess=DataAccessKind.None, IsDeterministic = true,IsPrecise = true)]
        public static SqlDecimal Multiply(SqlDecimal a, SqlDecimal b)
        {
            if (a.IsNull || b.IsNull) return SqlDecimal.Null;
            return a*b;
        }
    }
}

SQL代码:

USE tempdb
GO
IF DB_ID('test') IS NOT NULL DROP DATABASE test
GO
CREATE DATABASE test
GO
USE test
GO

CREATE ASSEMBLY TestMultiplySQLDecimal 
FROM 'C:\Users\tralalalaa\Documents\visual studio 2015\Projects\TestMultiplySQLDecimal\TestMultiplySQLDecimal\bin\Release\TestMultiplySQLDecimal.dll'
WITH PERMISSION_SET = SAFE
GO

CREATE FUNCTION dbo.fn_multiply(@a decimal(38,8), @b decimal(18,8)) 
RETURNS decimal(38,8)
EXTERNAL NAME TestMultiplySQLDecimal.[TestMultiplySQLDecimal.Multiplier].Multiply
GO
DECLARE @a decimal(38, 8),
        @b decimal(18, 8),
        @c decimal(38, 8),
        @f decimal(38, 8)

SELECT @a = -0.00000450,
       @b = 0.193,
       @c = NULL,
       @f = NULL

SELECT @c = @a * @b,
       @f = dbo.fn_multiply(@a, @b)

SELECT multiply = null, c = @c, f = @f

结果是: c = -0.00000100 f = +0.00000100

我知道 'absolute' 的区别是 'minimal' 我有 "played down" 更大的错误归咎于 "rounding differences"...但这很难解释对客户来说,消极乘以积极会产生积极的结果。 毕竟,T-SQL 支持得很好...

我可以尝试使用 decimal(28,8) 而不是 decimal(38,8) 来解决它,但我会 运行 进入其他(完全不相关的)问题然后 =/


以下控制台应用程序表现出相同的问题,但无需 SQL Server/SQLCLR 参与:

using System;
using System.Data.SqlTypes;

namespace PlayAreaCSCon
{
    class Program
    {
        static void Main(string[] args)
        {
            var dec1 = new SqlDecimal(-0.00000450d);
            var dec2 = new SqlDecimal(0.193d);
            dec1 = SqlDecimal.ConvertToPrecScale(dec1, 38, 8);
            dec2 = SqlDecimal.ConvertToPrecScale(dec2, 18, 8);
            Console.WriteLine(dec1 * dec2);
            Console.ReadLine();
        }
    }
}

打印 0.000001

我认为错误位于行 1550 of SqlDecimal:

附近
ret = new SqlDecimal(rgulRes, (byte)culRes, (byte)ResPrec, 
                     (byte)ActualScale, fResPositive);

if (ret.FZero ())
     ret.SetPositive();

ret.AssertValid();

ret.AdjustScale(lScaleAdjust, true);

return ret;

它首先使用最终的比例参数构造一个新的小数。接下来根据传入的构造函数参数检查结果是否为 "zero"。

然后,在断言一切都有效后,它会执行比例调整。

执行 FZero 检查时,结果类似于 -0.0000008685。我们知道 最终 比例将为 6,因为我们已达到结果 scale and precision 的极限。好吧,前6位全为零。

只是在那之后,在调整刻度时,它才考虑四舍五入并将1移动到最后的小数位。

这是一个错误。不幸的是,decimal 的 SQL 服务器本机实现的源代码并未公开,因此我们无法将其与 SqlDecimal 的托管实现进行比较,以了解它们之间的相似程度和方式原版避免了同样的错误。

虽然 T-SQL 和 .NET 实现之间的行为差​​异是 "troubling" 并且确实指向一个错误,而 @Damien_The_Unbeliever 的 可能很好地确定了这种行为的原因(目前很难验证,因为 SqlDecimal 实现中有很多代码,其中一些代码使用 double 来绕过 .NET 不支持的不精确计算超过 28 位),这里很可能忽略了一个更大的问题:两个答案(即 c = -0.00000100 f = +0.00000100 )都是错误的!也许我们不应该那么仓促地决定"The sky is plaid"和"The sky is polka-dotted"之间的赢家;-)

在这种情况下,我们可能需要在目标上更加务实一些,更多地了解 Decimal 运算的局限性,并扩大我们的测试范围。

首先,虽然为未知输入集保留最大数据类型 space 似乎是个好主意,但使用 DECIMAL(38, y) 类似于使用 NVARCHAR(MAX)所有字符串。是的,它通常可以容纳你扔给它的任何东西,但也有后果。考虑到结果精度和小数位数的计算方式的性质,小数运算还有一个额外的结果,尤其是,因为 "scale"(即 8 位数字)的空间太小) 并且乘以非常小的数字。意思是:如果你不打算使用小数点左边的 30 位数字的完整范围(即 DECIMAL(38, 8) ),那么不要指定 DECIMAL(38, 8)。对于输入参数,只需指定每个值允许的最大大小。鉴于两者都低于 0,使用类似 DECIMAL(20, 18) (甚至 DECIMAL(18, 8) )的东西不仅非常灵活,而且会产生正确的结果。或者,如果您确实需要允许较大的值,则通过指定 DECIMAL(38, 28) 之类的内容,为小数点右侧的数字(即 "scale")提供更多 space小数点左边10位,右边28位。

所有内容的原始 DECIMAL(38, 8)

DECLARE @a DECIMAL(38, 8),
        @b DECIMAL(38, 8),
        @c DECIMAL(38, 8);

SELECT @a = -0.00000450,
       @b = 0.193;

SELECT @c = @a * @b;

SELECT @a * @b AS [RawCalculation], @c AS [c];

Returns:

RawCalculation    c
-0.000001         -0.00000100

使用 DECIMAL(18, 8)

DECLARE @a DECIMAL(18, 8),
        @b DECIMAL(18, 8),
        @c DECIMAL(38, 18),
        @d DECIMAL(20, 18),
        @e DECIMAL(38, 8);

SELECT @a = -0.00000450,
       @b = 0.193;

SELECT @c = @a * @b,
       @d = @a * @b,
       @e = @a * @b;

SELECT @a * @b AS [RawCalculation], @c AS [c], @d AS [d], @e AS [e];

Returns:

RawCalculation          c                         d                         e
-0.0000008685000000     -0.000000868500000000     -0.000000868500000000     -0.00000087

使用 DECIMAL(38, 28)

DECLARE @a DECIMAL(38, 28),
        @b DECIMAL(38, 28),
        @c DECIMAL(38, 18),
        @d DECIMAL(20, 18),
        @e DECIMAL(38, 8);

SELECT @a = -0.00000450,
       @b = 0.193;

SELECT @c = @a * @b,
       @d = @a * @b,
       @e = @a * @b;

SELECT @a * @b AS [RawCalculation], @c AS [c], @d AS [d], @e AS [e];

Returns:

RawCalculation          c                         d                            e
-0.00000086850000000    -0.000000868500000000     -0.000000868500000000        -0.00000087

.NET示例代码

以下代码基于@Damien 添加到问题中的示例代码。我将其扩展以进行额外的测试,以显示精度和比例的变化如何影响计算,并在每一步输出各种属性。请注意,.NET 中小数的字面表示使用 Mm,而不是 d(尽管在本次测试中没有影响):decimal (C# Reference)

using System;
using System.Data.SqlTypes;

namespace SqlDecimalMultiplication
{
    class Program
    {
        private static void DisplayStuffs(SqlDecimal Dec1, SqlDecimal Dec2)
        {
            Console.WriteLine("1 ~ {0}", Dec1.Value);
            Console.WriteLine("1 ~ Precision: {0}; Scale: {1}; IsPositive: {2}", Dec1.Precision, Dec1.Scale, Dec1.IsPositive);
            Console.WriteLine("2 ~ {0}", Dec2.Value);
            Console.WriteLine("2 ~ Precision: {0}; Scale: {1}; IsPositive: {2}", Dec2.Precision, Dec2.Scale, Dec2.IsPositive);

            Console.Write("\nRESULT:    ");
            Console.ForegroundColor = ConsoleColor.White;
            Console.WriteLine(Dec1 * Dec2);
            Console.ResetColor();

            return;
        }

        static void Main(string[] args)
        {
            var dec1 = new SqlDecimal(-0.00000450m);
            var dec2 = new SqlDecimal(0.193m);

            Console.WriteLine("=======================\n\nINITIAL:");
            DisplayStuffs(dec1, dec2);


            dec1 = SqlDecimal.ConvertToPrecScale(dec1, 38, 8);
            dec2 = SqlDecimal.ConvertToPrecScale(dec2, 18, 8);

            Console.WriteLine("=======================\n\nAFTER (38, 8) & (18, 8):");
            DisplayStuffs(dec1, dec2);


            dec1 = SqlDecimal.ConvertToPrecScale(dec1, 18, 8);

            Console.WriteLine("=======================\n\nAFTER (18, 8) & (18, 8):");
            DisplayStuffs(dec1, dec2);


            dec1 = SqlDecimal.ConvertToPrecScale(dec1, 38, 28);
            dec2 = SqlDecimal.ConvertToPrecScale(dec2, 38, 28);

            Console.WriteLine("=======================\n\nAFTER (38, 28) & (38, 28):");
            DisplayStuffs(dec1, dec2);

            Console.WriteLine("=======================");
            //Console.ReadLine();
        }
    }
}

Returns:

=======================

INITIAL:
1 ~ -0.00000450
1 ~ Precision: 8; Scale: 8; IsPositive: False
2 ~ 0.193
2 ~ Precision: 3; Scale: 3; IsPositive: True

RESULT:    -0.00000086850
=======================

AFTER (38, 8) & (18, 8):
1 ~ -0.00000450
1 ~ Precision: 38; Scale: 8; IsPositive: False
2 ~ 0.19300000
2 ~ Precision: 18; Scale: 8; IsPositive: True

RESULT:    0.000001
=======================

AFTER (18, 8) & (18, 8):
1 ~ -0.00000450
1 ~ Precision: 18; Scale: 8; IsPositive: False
2 ~ 0.19300000
2 ~ Precision: 18; Scale: 8; IsPositive: True

RESULT:    -0.0000008685000000
=======================

AFTER (38, 28) & (38, 28):
1 ~ -0.0000045000000000000000000000
1 ~ Precision: 38; Scale: 28; IsPositive: False
2 ~ 0.1930000000000000000000000000
2 ~ Precision: 38; Scale: 28; IsPositive: True

RESULT:    -0.00000086850000000
=======================

结论

虽然 SqlDecimal 中可能存在错误,但如果指定 输入参数的精度和小数位数 "properly",您应该不会遇到它.当然,除非您确实确实需要传入值的 38 位数字,但大多数用例永远不需要它。

此外,在上面的段落中突出显示“输入参数”的原因是表明 return 值 自然应该是更大的精度(和比例),以适应由于某些操作导致的精度 and/or 比例的增加。因此,将 DECIMAL(38, 28)DECIMAL(38,18) 保留为 return 值的数据类型没有任何问题。


相关说明:

对于 SQLCLR UDF(即标量函数),如果它涵盖 所有 个输入参数,请勿使用此模式:

if (a.IsNull || b.IsNull) return SqlDecimal.Null;

如果想法是 return a NULL 如果输入参数的 anyNULL,那么你应该使用下面的CREATE FUNCTION 语句中的选项:

WITH RETURNS NULL ON NULL INPUT

因为这将避免完全调用 .NET 代码!