在C#中创建一个百分比类型

12
我的应用程序经常涉及百分比。通常情况下,这些百分比以书面形式存储在数据库中,而不是十进制形式(例如,50%将被存储为50,而不是0.5)。还有一个要求,即应用程序中的百分比格式必须保持一致。
为此,我考虑创建一个称为"percentage"的结构体来封装此行为。我想它的签名看起来应该像这样:
public struct Percentage
{
    public static Percentage FromWrittenValue();
    public static Percentage FromDecimalValue();

    public decimal WrittenValue { get; set; }
    public decimal DecimalValue { get; set; }
}
这样做是否合理?这肯定会封装一些重复多次的逻辑,但这是一种直观易懂的逻辑。我想尽可能使这种类型像普通数字一样运行,但我担心在从十进制到其他进制的隐式转换方面可能会进一步混淆人们。
有没有关于如何实现这个类的建议?或者有令人信服的理由不要这样做。
8个回答

15

我对这里对于数据质量的漫不经心感到有些惊讶。 不幸的是,口头上的“百分比”一词可能表示两种不同的含义:概率和方差。OP没有指定是哪一个,但由于方差通常会被计算,我猜他可能是指百分比作为概率或分数(例如折扣)。

编写一个Percentage类的极为重要的原因并不在于呈现方式,而是要确保您防止那些愚蠢的用户输入无效值,如-5和250。

我认为更应该使用一个Probability类:一个数字类型,其有效范围严格为[0,1]。您可以在一个地方封装该规则,而不是在37个地方编写此类代码:

 public double VeryImportantLibraryMethodNumber37(double consumerProvidedGarbage)
 {
    if (consumerProvidedGarbage < 0 || consumerProvidedGarbage > 1)
      throw new ArgumentOutOfRangeException("Here we go again.");

    return someOtherNumber * consumerProvidedGarbage;
 }

相反,你有这个不错的实现。不,它并不是明显的改进,但要记住,在每次使用该值时都要进行值检查。

 public double VeryImportantLibraryMethodNumber37(Percentage guaranteedCleanData)
 {
    return someOtherNumber * guaranteedCleanData.Value;
 }

如果-5%和250%是有效的百分比怎么办。在某些情况下,它们确实是有效的。 - Mister Bee
在那些值有效的情况下,这个类就不适用了。没有一个类适用于每种情况,事实上,如果你在使用SOLID进行正确的设计,一个类应该只适用于一种情况。在概率/可能性情况下,它们很多时候都是无效的。零是最小可能值(比不可能更不可能),而一是最高可能值(比确定更确定)。 - Michael Blackburn

6
Percentage类不应该关心UI的格式。相反,应该实现IFormatProviderICustomFormatter来处理格式化逻辑。
至于转换,我会选择标准的TypeConverter路线,这将允许.NET正确处理此类,再加上一个单独的PercentageParser实用程序类,它将委托调用TypeDescriptor以在外部代码中更易于使用。此外,如果需要的话,您可以提供implicitexplicit转换运算符。
至于Percentage,除了语义表达之外,我没有看到任何强制性原因将简单的decimal包装成一个单独的struct

我很赞同逻辑应该由其他类提供的观点。但在我看来,拥有一个百分比类仍然具有很大的价值,它使用默认的ToString方法来生成漂亮的百分比字符串。特别是因为这需要在多个展示层中显示。(包括Asp.Net、Winforms和pdf报告的混合)。 - Jack Ryan
ToString(),就像其他.NET类一样,可以将所有工作委托给IFormatProvider和ICustomFormatter。请参见DateTime.ToString的示例。 - Anton Gogolev
当然,总是编写完美代码的人永远不必担心语义表达之类的问题。他们再也不会看着自己的完美代码想知道到底发生了什么。-- 如果你需要更具体的原因,百分比类型可能非常有用,只要你将其作为一个封装在0到1之间的十进制数的类。这非常适用于概率应用。每当我需要对界面中滑块控件的输出或神经网络中的权重等范围内数字进行建模时,我都会使用此类型。 - Michael Blackburn

4

这似乎是个合理的做法,但我建议你重新考虑你的接口,使其更像其他CLR原始类型,例如:

// all error checking omitted here; you would want range checks etc.
public struct Percentage
{
    public Percentage(decimal value) : this()
    {
        this.Value = value
    }

    public decimal Value { get; private set; }

    public static explicit operator Percentage(decimal d)
    {
        return new Percentage(d);
    }

    public static implicit operator decimal(Percentage p)
    {
        return this.Value;
    }

    public static Percentage Parse(string value)
    {
        return new Percentage(decimal.Parse(value));
    }

    public override string ToString()
    {
        return string.Format("{0}%", this.Value);
    }
}

你肯定也想要实现IComparable<T>IEquatable<T>,以及所有对应的操作符和EqualsGetHashCode等的覆盖。你可能还想考虑实现IConvertibleIFormattable接口。
这是一项很大的工程。结构体可能会有1000行左右,并需要几天时间完成(我知道这点,因为前几个月我写了一个类似的Money结构体)。如果这对你来说具有成本效益,那就去做吧。

如果他坚持采用这种方法,也值得重载所有算术和比较运算符。 - Noldorin
@Noldorin - 呃,我在这些接口的上下文中说过“以及所有相应的运算符和Equals、GetHashCode等覆盖实现”? - Greg Beech

3
即使在2022年,我发现自己仍然在使用类似这样的东西。我同意Michael对OP的答案,并希望将其扩展到未来的Googlers。
创建值类型在解释领域意图时具有不可替代性,并强制实现不变性。特别是在分数记录中,您将获得一个通常会导致异常的商数,但是在这里,我们可以安全地显示d / 0而无需错误,同样,所有其他继承的子级也被授予了保护(它还提供了一个极好的地方来建立简单的例程来检查有效性、数据再生(好像DBA不会犯错)、序列化问题等等)。
namespace StackOverflowing;

// Honor the simple fraction
public record class Fraction(decimal Dividend, decimal Divisor) 
{
    public decimal Quotient => (Divisor > 0.0M) ? Dividend / Divisor : 0.0M;

    // Display dividend / divisor as the string, not the quotient
    public override string ToString() 
    {
        return $"{Dividend} / {Divisor}";
    }
};

// Honor the decimal based interpretation of the simple fraction
public record class DecimalFraction(decimal Dividend, decimal Divisor) : Fraction(Dividend, Divisor)
{
    // Change the display of this type to the decimal form
    public override string ToString()
    {
        return Quotient.ToString();
    }
};

// Honor the decimal fraction as the basis value but offer a converted value as a percentage
public record class Percent(decimal Value) : DecimalFraction(Value, 100.00M)
{
    // Display the quotient as it represents the simple fraction in a base 10 format aka radix 10
    public override string ToString()
    {
        return Quotient.ToString("p");
    }
};

// Example of a domain value object consumed by an entity or aggregate in finance
public record class PercentagePoint(Percent Left, Percent Right) 
{ 
    public Percent Points => new(Left.Value - Right.Value);

    public override string ToString()
    {
        return $"{Points.Dividend} points";
    }
}



[TestMethod]
public void PercentScratchPad()
{
    var approximatedPiFraction = new Fraction(22, 7);
    var approximatedPiDecimal = new DecimalFraction(22, 7);
    var percent2 = new Percent(2);
    var percent212 = new Percent(212);
    var points = new PercentagePoint(new Percent(50), new Percent(40));

    TestContext.WriteLine($"Approximated Pi Fraction: {approximatedPiFraction}");
    TestContext.WriteLine($"Approximated Pi Decimal: {approximatedPiDecimal}");
    TestContext.WriteLine($"2 Percent: {percent2}");
    TestContext.WriteLine($"212 Percent: {percent212}");
    TestContext.WriteLine($"Percentage Points: {points}");
    TestContext.WriteLine($"Percentage Points as percentage: {points.Points}");
}

PercentScratchPad 标准输出: TestContext 消息:

Approximated Pi Fraction: 22 / 7
Approximated Pi Decimal: 3.1428571428571428571428571429
2 Percent: 2.00%
212 Percent: 212.00%
Percentage Points: 10 points
Percentage Points as percentage: 10.00%

3
这个问题让我想起了《企业应用架构模式》中提到的Money类- 这个链接可能会给你一些启示。

2
我强烈建议您在此处仅使用double类型(我认为也没有任何使用decimal类型的必要,因为在低小数位上实际上不需要十进制精度)。通过在此处创建一个Percentage类型,您真的正在执行不必要的封装,并使代码中的值更难处理。如果您使用一个double,这在存储百分比时是惯常做法(在许多其他任务中也是如此),您会发现在大多数情况下,处理BCL和其他代码会更加轻松。
我能看到您需要的唯一额外功能是能够轻松地将其转换为/从百分比字符串。这可以非常简单地使用单行代码或扩展方法来完成,如果您想稍微抽象化它的话。
将其转换为百分比字符串:
public static string ToPercentageString(this double value)
{
    return value.ToString("#0.0%"); // e.g. 76.2%
}

将百分比字符串转换为数字:

public static double FromPercentageString(this string value)
{
    return double.Parse(value.SubString(0, value.Length - 1)) / 100;
}

如果需要轻松地从字符串进行转换,则编写扩展方法可能会很有用。 - RichardOD
@RichardOD:你猜对了我的想法。我刚刚在编辑帖子,加上了扩展方法的示例。 - Noldorin

1

我认为你可能混淆了表现和逻辑。当从数据库获取百分比时,我会将其转换为十进制或浮点小数(0.5),然后让表现处理格式。


1

我不会为此创建一个单独的类 - 这只会增加更多的开销。我认为使用设置为数据库值的double变量会更快。

如果普遍知道数据库将百分比存储为50而不是0.5,那么每个人都会理解像part = (percentage / 100.0) * (double)value这样的语句。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接