65.9K
CodeProject 正在变化。 阅读更多。
Home

使用单位和数量

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (76投票s)

2013年6月26日

CPOL

17分钟阅读

viewsIcon

94045

downloadIcon

1527

为您的业务和物理应用程序提供终极单位和数量类!

预览

是否曾想过有更好的书写方式?

double surface = 4.52 * 2.30;
double height = 10.0;
double volume = surface * height;

特别是当表面积以平方米表示,而高度以厘米表示时?

是否可以创建一个“数量”类来处理所有单位和数量的转换,让您可以按如下方式编写代码,即使表面积和高度的单位不同也能保持有效?

var volume = surface * height;

引言

处理以单位表示的数量通常是一个容易出错的任务,因为默认的.NET类型对此没有真正支持。例如,考虑以下代码示例:

double distance = 100.0;
double time = 2.5;
double speed = distance / time;

速度应该是40,对吗?但这40是米/秒,还是40公里/小时?或者40公里/秒?程序员所做的单位假设没有任何编译器检查或运行时检查。即使是人也无法通过查看代码来判断。

一种做法是在变量名后面加上其值的单位。这是一个积极的做法,因为它提高了代码的可读性,但它不能避免单位转换问题。从编译器的角度来看,以下代码是完全有效的,并且不会抛出任何运行时异常:

double distanceInKilometers = 100.0;
double timeInHours = 2.5;
double speedInMilesPerHour = distanceInKilometers / timeInHours;

这种做法也未能解决这样一个问题:单位转换(例如乘以或除以10或1000)很可能会散布在整个应用程序代码中。

假设我想比较一辆时速80公里的汽车的速度和一个以每秒40米的速度下落的物体的速度。哪个更快?

double carspeedInKmPerHour = 80;
double fallingspeedInMPerSec = 40;
if (carspeedInKmPerHour > fallingspeedInMPerSec)
    System.out.println("Car is faster");

这段代码显然是(通过命名约定可以清楚地看出)错误的,但如何修复它?

正确的比较应该是(不,汽车并不更快):

if ((carspeedKmPerHour * 0.277777777777778) > fallingspeedMPerSec)

您可以轻松地想象包含此类转换的代码散布在应用程序的多个层中。这与DRY原则(Don't Repeat Yourself)相冲突。

当然,您可以在某处创建一个函数ConvertKmPerHourToMPerSec(),但您每次需要将秒转换为毫秒时都会这样做吗?

本文描述了一种解决此问题的通用方法。

每个单位一个类

解决方案是创建面向对象的类来封装数量和单位的行为,通过实现支持我们领域语言的类来引入更高层次的抽象。

我们将设计一个系统,其中每个单位都创建一个类来表示该单位的数量。这可以给出以下类:

public class Meter {
 private double value;
 public Meter(double value)
 {
  this.value = value;
 }
}
public class Kilometer {
 private double value;
 public Kilometer(double value)
 {
  this.value = value;
 }
 public Meter ToMeter()
 {
  return new Meter(this.Value * 1000.0);
 }
}
public class Second
 private double value;
 public Second(double value)
 {
  this.value = value;
 }
}
public class Hour
 private double value;
 public Hour(double value)
 {
  this.value = value;
 }
 public Second ToSecond()
 {
  return new Second(this.Value * 3600.0);
 }
}

接下来添加MeterPerSecond、MeterPerHour、KilometerPerSecond和KilometerPerHour类,包含所有可能的To-转换方法,并为所有允许的+, -, *, /, >, <, >=, <=, ==和!=运算符组合添加重载。例如,Meter类可以具有以下/运算符重载:

public static MeterPerSecond operator /(Meter left, Second right)
{
 return new MeterPerSecond(left.Value / right.Value);
}
public static MeterPerHour operator /(Meter left, Hour right)
{
 return new MeterPerHour(left.Value / right.Value);
}

结果是一种非常安全的编码方式,它将确认我们的汽车并不比下落的物体快。

var carspeed = new KilometerPerHour(80);
var fallingspeed = new MeterPerSecond(40);
if (carspeed > fallingspeed)
    System.out.println("Car is faster");

但代价是什么?如果我们想组合多种长度单位(米、公里、厘米、英寸、码、英尺、英里……)、表面单位(平方米、平方公里、平方厘米、平方英寸、平方码、平方英尺、平方英里……)、体积单位(立方米、立方公里、立方厘米……)以及时间单位(秒、毫秒、纳秒、分钟、小时)、速度单位(米/秒、公里/小时、厘米/天……)和加速度单位(米/秒²、公里/小时²……),我们将最终得到大量的代码,而且仍然可能无法满足我们应用程序的所有(未来)需求。

此解决方案也很容易出错。事实上,考虑到创建的大量代码主要是通过复制粘贴转换因子和+、-、*、/、<、>、<=、>=、==和!=运算符,很可能我们在这里和那里混淆了其中的一些……

最后,类的可重用性非常有限。在另一个应用程序中,我们可能需要质量和能量单位,我们必须从头开始!

但是这种方法也有好处。首先,所有数量的操作和转换都硬编码在编译后的代码中,使得单位转换在编译时得到检查!其次,由于不需要在运行时执行单位类型评估和转换,因此这种方法也应该具有更好的性能。

如果您对此方法感兴趣,我建议您仔细研究一下http://units.codeplex.com/,这是一个轻量级的单位库,它使用代码生成来减轻您许多重复且易出错的编码工作。

更动态的方法 – 单位如何工作

我采取的完全不同的方法是创建一个有限的类集来处理所有类型的单位。要理解这些类,我们首先需要了解单位和数量在现实生活中是如何工作的。

国际单位制(SI)是一个标准化的测量系统,是理解单位和以这些单位表示的数量的一个很好的起点。
http://en.wikipedia.org/wiki/International_System_of_Units

SI定义了7个基本单位:米(长度)、千克(质量)、秒(时间)、安培(电流)、开尔文(温度)、坎德拉(发光强度)和摩尔(物质的量)。(长度、质量、时间……有时被称为维度。)所有这些基本单位都不能用其他单位来表示。例如,我可以将表面积表示为长度的平方,将体积表示为长度的立方,将速度表示为长度除以时间,但我不能用其他6个基本单位中的任何一个来定义长度。

因此,所有数量都可以用7个基本单位来表示。例如:

speed = 3 kilometer per hour
      = 3 (1000 meter) / (3600 second)
      = 3 * (1/3.6) * meter^1 * second^-1
      = 3 * 0.2777 * meter^1 * second^-1

这也显示了单位的结构。由基本单位导出的单位由一个因子(2.777)和每个基本单位的指数组成(对于除米和秒单位以外的所有单位都为0,而它们分别具有指数1和-1)。

从概念上讲,我们可以将每个数量表示为原始值和单位的组合。单位由一个因子和一组7个指数组成,每个指数对应一个SI基本单位。

3公里/小时的数量可以表示为:

现在,如果我以每小时3公里的速度行走,并以每秒12米的速度向前扔一个球。球以什么速度撞到我前面的墙?要回答这个问题,我需要将我的步行速度加到我扔球的速度上。

只有当数量以相同类型的单位表示时,才能添加或减去数量。也就是说,它们必须具有相同基本单位指数值的集合(此处分别为长度和时间的1和-1)。这条规则也适用于使用<或>运算符比较数量,因为我们不能比较不兼容单位的数量。

规则1:如果单位具有相同基本单位的相同指数值,则它们是“兼容的”。

每秒12米的数量表示为:

为了将3公里/小时和12米/秒相加,我们首先需要将一个数量转换为与另一个数量完全相同的单位。也就是说,不仅指数值必须相同,我们还需要使单位的因子相同。例如,我们可以按照以下规则将3公里/小时转换为米/秒:

3 * 0.2777777 = 0.8333333 * 1

因此,3公里/小时也可以写成:

现在我们可以简单地将它加到12米/秒上得到:

答案是:每秒12.8333米(或每小时46.2公里)。

规则2:兼容单位的数量在将它们转换为完全相同的单位后(使用单位因子作为转换因子)可以进行比较、相加或相减。

现在,另一个问题。如果墙在我前面25英尺远,球需要多长时间才能撞到墙?

已知速度和距离,我可以计算出持续时间:

time = distance / speed

(因为速度=距离/时间)。

距离是25英尺(25 * 0.3048米),因此:

要执行距离/时间的操作,我们需要将距离的原始值除以时间的原始值,将距离的因子除以时间的因子,并从时间的基数单位指数中减去距离的基数单位指数。

或者简化为:

注意这里的奇怪单位:这是一个0.3秒的单位表示的1.95的数量!所以这大约是0.64秒。

规则3:可以通过相乘/相除因子以及相加/相减基数单位指数来相乘/相除单位。

正如我们所见,单位和数量的行为可以通过将单位表示为带有基数单位指数集的因子来大大概括。而这正是Amount和Unit类所做的。

Amount和Unit类

基于这个概念,我开发了Unit、UnitType和Amount类,它们可以处理单位和数量的操作。

UnitType表示一种单位类型,如长度(距离)、时间或质量。有了这个单位类型,就可以为该维度创建基本单位。然后,可以从那里创建单位。例如:

UnitType length = new UnitType("length");
Unit meter = new Unit("meter", "m", length);
Unit kilometer = new Unit("kilometer", "km", 1000 * meter);
Unit squareMeter = new Unit("meter²", "m²", meter * meter);
Unit cubicMeter = new Unit("meter³", "m³", meter.Power(3));
Unit liter = cubicMeter / 1000.0;

请注意,新单位是如何通过相乘(或相除)先前创建的单位来创建的!

现在我们有了单位,就可以使用它们来创建数量:

var rainfall = new Amount(4.0, liter / squareMeter);

这将是4毫米。请注意,我们从未定义过“毫米”单位。这没关系。通过将升除以平方米,动态地创建了一个等同于毫米的单位。

需要注意的一个重要事项是,动态单位可以是任何东西。例如,动态单位可能匹配3.7米。这不是我们习惯使用的单位。因此,在显示数量时,建议先将其转换为已知单位,或使用格式化选项(稍后解释)来转换单位。

Console.WriteLine(rainfall.ConvertedTo(meter));

Console.WriteLine("{0:#,##0.00 US|meter}", rainfall);

到目前为止,这是对使用Unit和Amount类的简要介绍。现在让我们更详细地了解这些类的功能。

数学运算符重载

在适用的地方重载了数学运算符,以便于编码。例如:

Amount a;
Amount b = new Amount(25.0, Meter);
Amount c = new Amount(3.0, Second);

a = b / c;

对数量进行除法或乘法运算会得到一个新的数量,其值和单位都经过了除法/乘法运算。

另一方面,加法和减法仅可能在兼容单位之间进行(即相同类型的单位,例如“长度/时间”)。否则将抛出转换失败异常。

比较运算符

此外,比较运算符<、>、<=、>=、==和!=也已重载,并且支持IEquatable和IComparable接口。

然而,需要注意的是,Amount在内部使用double来存储其值,并且正如您可能已经知道的,double的算术运算总是有些模糊。特别是在比较double值时,有时两个相似的值可能不相等。有关背景信息,请参阅http://en.wikipedia.org/wiki/Floating_point#Accuracy_problems

因此,Amount类有一个静态的EqualityPrecision属性,它指定了在比较数量时考虑的小数位数。默认情况下,考虑8位小数。这基本上意味着在比较之前,值会被四舍五入到8位小数。

小数位数取决于单位的尺度。毫米的第一位小数只占毫米的十分之一,而公里第一位小数是100米!因此,当使用未知/动态单位或左右项使用不同单位进行比较时,精确到小数位数可能会产生意外的结果。在执行比较之前,将数量转换为已知单位更安全。

可以处理任何单位,真的任何单位!

类的实现不限于7个SI基本单位类型。实际上,您需要自己定义所有单位类型。您定义的单位类型的数量没有限制。

这可能会派上用场,因为使用SI单位类型存在一个问题。为了表示液体量,我通常会使用体积单位,例如升。如果我的应用程序处理牛奶和燃料,两者都是液体,我将使用升来表示两者。由于升可以加到升上,所以没有任何东西可以阻止我将1升牛奶添加到1升燃料中,得到2升混合物。这没有意义。我们不想将牛奶与燃料混合,就像我们不能比较苹果和橙子一样,不是吗?

我们可以通过定义两个不同的单位类型“volumeOfMilk”和“volumeOfFuel”来解决这个问题。这样,1升牛奶将以与1升燃料不同的单位类型表示,并且由于不同单位类型不能相加(就像您不能将3秒加到12米上一样),牛奶和燃料就不会混合。(尝试这样做会导致运行时异常。)

对于您确实想要使用SI单位的情况,代码附带的标准单位程序集定义了SI单位类型及其最常见的单位以及一些常用的物理常数。您可以使用它们来计算地球的质量,应用在http://www.enchantedlearning.com/subjects/astronomy/planets/earth/Mass.shtml找到的公式。

var a = new Amount(9.81, LengthUnits.Meter / TimeUnits.Second.Power(2));
var r = new Amount(6371.0, LengthUnits.KiloMeter);
var G = PhysicsConstant.NewtonianGravitation;

var M = a * r.Power(2) / G;

Console.WriteLine(M);
Console.WriteLine(M.ConvertedTo(MassUnits.Ton));

命名单位和UnitManager

代码还包含一个UnitManager类,这是一个静态类,可以帮助您查找预定义的单位。
您可以将单位注册到UnitManager,当您这样做时,可以通过名称检索单位。几个构造函数和其他方法允许您传递字符串而不是单位。当您使用这样的方法时,字符串应该与您之前在UnitManager中注册的单位名称匹配。例如:

anAmount = new Amount(12.5, "kilogram");

UnitManager还可以用于注册自定义转换函数和UnitResolve委托(用于解析尚未注册的单位)。

UnitManager的使用是可选的。您可以不注册单位就使用单位。您唯一会错过的是按名称或符号查找单位的能力。

显式数量转换

在进行一些计算后,数量通常以动态单位表示。这种动态单位对于计算是正确的,但它们的名称(通过连接构建)和尺度(因子)可能看起来很奇怪。因此,在将数量值保存到数据库或在屏幕上显示数量之前,应将其转换为已知单位。

Console.WriteLine(rainfall.ConvertedTo(LengthUnits.MilliMeter));

(或使用UnitManager注册的单位名称)

Console.WriteLine(rainfall.ConvertedTo("millimeter"));

四舍五入值也意味着您知道值是以哪个单位表示的。因此,Amount类没有Round方法,但是有ConvertedTo的重载允许四舍五入,例如:

var rainfallRounded = rainfall.ConvertedTo(LengthUnits.MilliMeter, 2);

一个单位是动态的还是非动态的可以通过IsNamed属性进行测试:如果单位已命名,则它不是动态的,反之亦然。

数量可以自定义格式化

Amount类有ToString()方法的几个重载,并实现了IFormattable。

.ToString()
.ToString(string format)
.ToString(IFormatProvider formatProvider)
.ToString(string format, IFormatProvider formatProvider)

formatProvider通常是CultureInfo对象,它将决定小数分隔符和千位分隔符。

格式字符串是常量格式字符串“GG”、“GN”、“GS”、“NG”、“NN”或“NS”,其中第一个字母代表值部分的通用或数字格式,第二个字母代表单位的通用、名称或符号渲染(通用和符号相同)。

但是格式字符串也可以指定自定义格式,后跟“UG”、“UN”或“US”,其中U代表单位的通用、名称或符号渲染。

最后,格式字符串还可以包含“|”符号,后跟要转换到的单位的名称(单位应在UnitManager中注册),或后跟“?”让UnitManager搜索匹配的单位。

如果没有提供格式字符串或为null,则假定为“GG”。

因此,以下格式字符串是有效的;提供了示例输出:

"GG" 1234.56789 公里
"NG" 1234.57 公里
"GN" 1234.56789 公里
"#,##0.0 US" 1,234.6 公里
"#,##0.0 UN" 1,234.6 公里
"#,##0.0 US|meter" 1,234,567.9 米



特别是最后一种形式很有趣,因为它强制转换为已知单位,并确保值不是以某种奇怪的动态单位表示的。

由于Amount实现了IFormattable,您也可以在String或Console方法的格式字符串中使用这些格式选项。例如:

Console.WriteLine("The result is {0:#,##0.00 US|liter}", amount);

Amount类还定义了方便的静态ToString()方法。静态方法的优点是它们也支持null金额(对于这些金额,它们返回空字符串)。例如,以下代码不会抛出异常:

Amount x = null;
String s = "The result is ";
s = s + Amount.ToString(x, "#,##0.00 US|meter");

单位和数量是可序列化的

在单位和数量的(标准.NET二进制)序列化/反序列化过程中,已采取特别的注意。当将单位或数量传递到另一个应用程序层(这涉及序列化/反序列化)时,代码会考虑到目标层可能尚未了解金额单位的事实。

因此,金额的序列化形式包含在目标AppDomain中重新创建单位和单位类型所需的所有信息。

XML序列化、(WCF)数据契约序列化和数据库序列化

另一方面,我没有提供开箱即用的数据契约序列化支持。有两个原因。第一个是,当数据成员属性的类型不支持数据契约序列化时,数据契约序列化器将回退到标准序列化,因此没有绝对必要提供显式支持。

第二个原因我认为更重要:数量和单位的序列化形式无论如何都需要客户端也使用这里定义的Amount和Unit类,这是一个我不想做出的假设。

让我们看看使用DataContractSerializer序列化以下类的一个实例时会发生什么:

[DataContract]
public class Car
{ 
    [DataMember]
    public int Id { get; set; }

 
    [DataMember]
    public string Name { get; set; }

 
    [DataMember]
    public Amount Speed { get; set; }
}

DataContractSerializer将此转换为如下内容:

<Car xmlns="http://schemas.datacontract.org/2004/07/..."
     xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
    <Id>1</Id>
    <Name>Beatle</Name>
    <Speed xmlns:a="http://schemas.datacontract.org/2004/07/TypedUnits">
        <a:unit>
            <a:factor>0.27777777777777779</a:factor>
            <a:isNamed>false</a:isNamed>
            <a:name>(kilometer/hour)</a:name>
            <a:symbol>km/h</a:symbol>
            <a:unitType>
                <names i:type="b:string" xmlns="" xmlns:b="...">meter|second</names>
                <exps i:type="b:string" xmlns="" xmlns:b="...">1|-1</exps>
            </a:unitType>
        </a:unit>
        <a:value>120</a:value>
    </Speed>
</Car>

所以序列化是有效的,但单位会产生相当大的开销。更重要的是,如果一方(即客户端)不使用Amount和Unit实现(即因为它是一个Java客户端),那么该方将很难解码金额。

因此,在我看来,首选解决方案是创建派生属性,以特定单位公开double。然后,您将序列化派生属性而不是Amount属性。例如,以下类可以通过WCF进行序列化,并通过Entity Framework映射到数据库:

[Table("Cars", Schema = "vehicles")]
[DataContract]
public class Car
{
    [Key]
    [DataMember]
    public int Id { get; set; }

 
    [DataMember]
    [Required, MaxLength(50)]
    public string Name { get; set; }

 
    [NotMapped]
    public Amount Speed { get; set; }

 
    [Column("Speed")]
    [DataMember]
    public double SpeedInKmPerSec
    {
        get
        {
            return this.Speed.ConvertedTo(SpeedUnits.KilometerPerHour).Value;
        }
        set
        {
            this.Speed = new Amount(value, SpeedUnits.KilometerPerHour);
        }
    }
}

DataContractSerializer现在生成:

<Car xmlns="http://schemas.datacontract.org/2004/07/..."
     xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
    <Id>1</Id>
    <Name>Beatle</Name>
    <SpeedInKmPerSec>120</SpeedInKmPerSec>
</Car>

这要短得多,并且更容易与例如Java客户端交换。

我知道,我知道!我们又回到了我在引言中提到的后缀变量名技巧。区别在于,我们在这里只将它们用于序列化,而计算是使用Amount类型属性“Speed”进行的。

如果您只关心DataContract序列化,甚至可以将SpeedInKmPerSec属性隐藏起来,使其成为私有或保护成员(DataContract序列化可以处理隐藏成员),这样其余代码将被强制使用Amount类型属性进行计算。

如果您关心Entity Framework,您也可以映射保护成员(请参阅Arthur Vickers的博客http://blog.oneunicorn.com/2012/03/26/code-first-data-annotations-on-non-public-properties/),但您应该知道,您必须始终在EF Linq查询中使用映射的属性,Entity Framework才能将其转换为SQL,因此您不能在EF Linq查询中使用那些受保护的成员(因为它们是受保护的)及其派生属性(因为它们无法转换为SQL)。

源代码

源代码包括一个Visual Studio.NET 2010解决方案(应该可以在VS.NET 2012中打开),并包含以下项目:

TypedUnits项目包含Amount、Unit、UnitType和UnitManager类的实际代码。StandardUnits程序集包含大多数常见SI单位和物理常数的定义。

TestProject是一个包含单元测试的项目,我用它来验证我的代码。

最后,SampleConsoleApplication只是一个非常简单的示例应用程序,可以帮助您入门。

尽情享用!

© . All rights reserved.