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

9天内制作一个动态度量单位库

starIconstarIconstarIconstarIconstarIcon

5.00/5 (28投票s)

2018年9月9日

CPOL

31分钟阅读

viewsIcon

42319

这个小型库以一种动态和多上下文的方法处理单位和数量。

引言

这是我在Code Project上的第一篇文章。我经常接触一些太大、太具体或太无用的项目,但这个项目足够小巧有趣,值得在这里分享。我花了9天时间来完成它,并记录了每一天的活动。我希望这篇文章能被看作是一篇“软件开发之旅记”。每一天都实现了功能并编写了单元测试,有时也进行重构(有时甚至回滚之前的代码):这就是典型的开发者生活。

形式上就说这么多。内容如何呢?

我需要处理单位和数量,主要是国际单位制(SI)的单位(但也可能包含更晦涩的单位),并且需要在一个数据库中持久化数量及其单位的系统中进行处理,同时允许多个“单位上下文”共存(可以想象成多租户)。

背景

我找到的C#解决方案都很有意思,但都不完全满足我的需求。这里简单谈谈其中两个:

如果你再深入一点,会发现F#及其标准单位:http://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/units-of-measure。函数式编程 shines!然而…

引用

运行时单位

单位用于静态类型检查。当浮点值被编译时,单位将被消除,因此单位在运行时会丢失。因此,任何试图实现依赖于运行时检查单位的功能都是不可能的。例如,实现一个ToString函数来打印单位是不可能的。

(http://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/units-of-measure#units-at-runtime)

我的需求更“动态”,更“数据驱动”(一个全新的单位可以在导入CSV或从Web API返回的JSON中创建),并且比我找到的方案更轻量级(最终结果是一个netstandard2.0的DLL,只有...32KB)。

限制

许多现有库都支持多语言、依赖区域性的文本单位表示。这个库则不。单位和数量的渲染是UI层面的关注点,应该在那里处理(就像日期和时间一样)。同样,非线性转换,如经典的°C到°F,也超出了范围。对于温度,SI单位是开尔文,显示或输入摄氏度或华氏度应该作为UI偏好(就像区域性一样)来处理。

最后但同样重要的是,货币:将$转换为€不在范围之内。货币可以定义为彼此独立的单位。你可以在一个房地产管理应用程序中操作“$/m²”和“€/m²”的数量,但实际的汇率(和转换)必须在别处处理。

摘要

第一天:基本单位

要理解这个,需要了解一些关于公制和量纲分析的知识。请阅读https://en.wikipedia.org/wiki/Metric_systemhttps://en.wikipedia.org/wiki/Dimensional_analysis

我们以SI(国际单位制)的7个基本单位作为起点:这些是FundamentalMeasureUnit。一个基本单位(对我们而言)在语义上等同于一个维度。我们定义了3个基本单位(维度),并且可以根据需要动态添加新的单位(例如,“$”和/或“€”、“£”等)。请注意,每种货币都应是一个维度。将“$”数量转换为“€”不在此库的范围内,必须在外部完成。

我们默认定义的三种基本单位是:

/// <summary>
/// Dimensionless unit. Associated abbreviation is "" (the empty string) and its
/// name is "None".
/// </summary>
public static readonly FundamentalMeasureUnit None;

/// <summary>
/// Dimensionless unit. Used to count items. Associated abbreviation is "#".
/// </summary>
public static readonly FundamentalMeasureUnit Unit;


/// <summary>
/// A bit is defined as the information entropy of a binary random variable
/// that is 0 or 1 with equal probability.
/// Associated abbreviation is "b" (recommended by the IEEE 1541-2002 and
/// IEEE Std 260.1-2004 standards).
/// </summary>
public static readonly FundamentalMeasureUnit Bit;

这些基本单位实际上是BasicMeasureUnit,其Exponent设置为1BasicMeasureUnit绑定到一个带指数的FundamentalMeasureUnit:它们处理量纲方程的基本项,如m^2、s^-1等。

NormalizedMeasureUnit包含一个或多个BasicMeasureUnit的列表。列表通过指数降序和基本单位名称的字典序进行归一化。

目前,一个简单的层次结构已经足够,以MeasureUnit 抽象类为根

图:初始模型

有了这个简单的模型,并借助C#运算符重载(*、/和^),我们可以实现这种有趣的量纲游戏(来自https://en.wikipedia.org/wiki/SI_derived_unit

var metre = MeasureUnit.Metre;
var second = MeasureUnit.Second;
var kilogram = MeasureUnit.Kilogram;
var ampere = MeasureUnit.Ampere;
var candela = MeasureUnit.Candela;
var squaredMeter = metre^2;

var hertz = MeasureUnit.None / second;
hertz.Abbreviation.Should().Be( "s-1" );

var rad = metre * (metre ^ -1);
rad.Should().BeSameAs( MeasureUnit.None );

var steradian = squaredMeter / squaredMeter;
steradian.Should().BeSameAs( MeasureUnit.None );

var newton = kilogram * metre * (second ^ -2);
newton.Abbreviation.Should().Be( "kg.m.s-2" );

var pascal = newton / squaredMeter;
pascal.Abbreviation.Should().Be( "kg.m-1.s-2" );

var joule = newton * metre;
var watt = joule / second;
var coulomb = ampere * second;
var volt = watt / ampere;

// Another definition of the volt:
var volt2 = joule / coulomb;

volt.Should().BeSameAs( volt2 );
volt.Abbreviation.Should().Be( "m2.kg.A-1.s-3" );

var farad = coulomb / volt;
farad.Abbreviation.Should().Be( "s4.A2.kg-1.m-2" );

var ohm = volt / ampere;
ohm.Abbreviation.Should().Be( "m2.kg.A-2.s-3" );

// Another definition of the farad.
var farad2 = second / ohm;
farad2.Should().BeSameAs( farad );

var siemens = MeasureUnit.None / ohm;
var siemens2 = ampere / volt;
siemens.Should().BeSameAs( siemens2 );
siemens.Abbreviation.Should().Be( "s3.A2.kg-1.m-2" );

var weber = joule / ampere;
var tesla = volt * second / squaredMeter;
var tesla2 = weber / squaredMeter;
var tesla3 = newton / (ampere * metre);

tesla2.Should().BeSameAs( tesla );
tesla3.Should().BeSameAs( tesla );
tesla.Abbreviation.Should().Be( "kg.A-1.s-2" );

var henry = ohm * second;
var henry2 = volt * second / ampere;
var henry3 = weber / ampere;
henry2.Should().BeSameAs( henry );
henry3.Should().BeSameAs( henry );
henry.Abbreviation.Should().Be( "m2.kg.A-2.s-2" );

var lumen = candela * steradian;
var lux = lumen / squaredMeter;
lux.Abbreviation.Should().Be( "cd.m-2" );

重要提示:上面的代码显示,当它们定义相同的单位时,对象实际上是同一个对象(引用相等)。这是本库的目标之一:即使随时可以动态创建新的度量单位,度量单位也必须完全归一化并缓存。这是通过ConcurrentDictionary实现的,度量单位的“Abbreviation”属性作为键。

虽然这很有效且很有趣,但这还不够。我们必须处理:

  • 前缀:标准前缀,如“d”/”Deci”(如“dm”/”Decimeter”)或“m”/”Milli”(如“mm”/”Millimeter”)。参见https://en.wikipedia.org/wiki/Metric_prefix。由于我们已经处理了位,所以也应该处理二进制前缀(参见https://en.wikipedia.org/wiki/Binary_prefix):如“Ki”/”Kibbi”(如“Kib”/”KibiBit”)。

    引入这一点不像看起来那么容易。我们必须能够透明地处理等价性,例如,米每平方秒(m.s^-2,即加速度)与微米每毫秒平方(μm.ms^-2)是相同的。

  • 导出单位:导出单位就像一个别名,例如“l”/”Liter”“dm3”相同,即10^-3 m³。
    一些实际使用的单位会引入一个因子(无法简单地通过指数化来表示,如升)。

    例如:

    • 牛顿(N):其定义直接使用基本单位:1 N = 1 kg.m.s^-2
    • 达因(dyn)定义为:1 dyn = 10^-5 N
    • 千克力(kp)是:1 kp = 9.80665 N

这肯定需要一个更复杂的模型。

第二天:处理前缀

让我们从标准前缀和二进制前缀开始。这些前缀(“G”/”Giga”“k”/”Kilo”“K”/”Kibi”“m”/”Milli”等)仅适用于非指数化的单位,如基本单位,并且可以在使用基本单位的任何地方使用。

分米(平方)是无效的,但平方分米是有效的。

为了正确建模,我们稍微细化模型,在与FundamentalMeasureUnit相同的(最低)级别引入PrefixedMeasureUnit

为了统一这两种类型,我们引入了一个新的AtomicMeasureUnit类,它是FundamentalMeasureUnitPrefixedMeasureUnit的基类。

NormalizedMeasureUnit现在处理一个或多个(可能带指数的)基本单位以及非“基本”的PrefixedMeasureUnit。它的名称(“normalized”)不再准确:我们将其改为CombinedMeasureUnit,以更好地反映其内容。

由于我们处理的是(糟糕的)名称,“Basic measure unit”并不能清晰地传达其含义。我们将BasicMeasureUnit重命名为ExponentMeasureUnit

新的类图是:

图:引入前缀和更好的命名

 

标准的SI前缀(全部)由MeasureStandardPrefix类通过一组单例捕获并公开。这个集合是不可扩展的(您不能定义自己的前缀)。

这些前缀可以应用于任何AtomicMeasureUnit以获得另一个AtomicMeasureUnit,因为前缀应用的结果可能是带前缀的单位或基本单位。

var centimetre = MeasureStandardPrefix.Centi.On( MeasureUnit.Metre );
centimetre.Abbreviation.Should().Be( "cm" );
centimetre.Name.Should().Be( "Centimetre" );

如果我们对这个厘米应用Hecto前缀,我们将得到(基本)米。

var hectocentimetre = MeasureStandardPrefix.Hecto.On( centimetre );
hectocentimetre.Should().BeSameAs( MeasureUnit.Metre );

下面是这个新游戏的所有测试:

[Test]
public void playing_with_decimetre_and_centimeter()
{
    var decimetre = MeasureStandardPrefix.Deci.On( MeasureUnit.Metre );
    decimetre.Abbreviation.Should().Be( "dm" );
    decimetre.Name.Should().Be( "Decimetre" );

    var decimetreCube = decimetre ^ 3;
    decimetreCube.Abbreviation.Should().Be( "dm3" );
    decimetreCube.Name.Should().Be( "Decimetre^3" );


    // This does'nt compile and this is perfect! :)
    //var notPossible = MeasureStandardPrefix.Deci.On( decimetreCube );


    var centimetre = MeasureStandardPrefix.Centi.On( MeasureUnit.Metre );
    centimetre.Abbreviation.Should().Be( "cm" );
    centimetre.Name.Should().Be( "Centimetre" );


    var decidecimetre = MeasureStandardPrefix.Deci.On( decimetre );
    decidecimetre.Should().BeSameAs( centimetre );

    var hectocentimetre = MeasureStandardPrefix.Hecto.On( centimetre );
    hectocentimetre.Should().BeSameAs( MeasureUnit.Metre );

    var kilocentimeter = MeasureStandardPrefix.Kilo.On( centimetre );
    kilocentimeter.Abbreviation.Should().Be( "dam" );

    var decametre = MeasureStandardPrefix.Deca.On( MeasureUnit.Metre );
    decametre.Should().BeSameAs( decametre );
}

总结第二天的内容,需要考虑一个并非极端的情况:标准前缀“不完整”,无法安全使用。如果你想要“Giga”(10^9)的“Deci”(10^-1)怎么办?没有10^8的前缀。类似的问题:什么是千亿亿(Yotta是最大的前缀:10^24)?

请注意,你可能无法直接询问,但在复杂系统中可能会间接发生。我们必须能够处理这些情况,即使我们总是会尝试*最终*避免这种“中间”前缀。

我们的想法是为PrefixedMeasureUnit引入一个“调整因子”。这个调整因子是一个ExpFactor,它使我们能够在不依赖浮点类型(及其固有局限性)的情况下处理指数。

/// <summary>
/// Immutable value type that captures 10^<see cref="Exp10"/>.2^<see cref="Exp2"/>.
/// </summary>
public struct ExpFactor : IComparable<ExpFactor>, IEquatable<ExpFactor>
{
   public static readonly ExpFactor Neutral; // The neutral factor (0,0).


   public readonly int Exp2; // The base 2 exponent.


   public readonly int Exp10; // The base 10 exponent.


   public ExpFactor Power( int p ) => new ExpFactor( Exp2 * p, Exp10 * p );


   public ExpFactor Multiply( ExpFactor x ) => new ExpFactor( Exp2 + x.Exp2, Exp10 + x.Exp10 );


   public ExpFactor DivideBy( ExpFactor x ) => new ExpFactor( Exp2 - x.Exp2, Exp10 - x.Exp10 );
}

注意:上面的struct中有更多的代码,这里只显示最相关的内容。

有两个指数:一个是以2为基数(因为我们支持二进制前缀),另一个是以10为基数,用于公制前缀。

这允许我们根据需要生成“中间前缀”或“超出范围的前缀”,并保持整个系统安全和一致,只要我们能找到一种表达/显示/识别这些“野兽”的方式。

如果调整因子不是中性的,它将出现在单位的缩写和名称中:“(10^-1)Gm”/“(10^-1)Gigameter”将是“Decigigametre”,这个单位不存在。当然,你永远不会遇到“(10^-1)cm”,因为这等于“mm”/“Millimetre”!

注意语法:我们故意选择了与标准指数(带基数和插入符号^)不同的语法,以便这些“野兽”可以轻松识别。

以下是第二天结束时的测试:

[Test]
public void playing_with_adjustment_factors()
{
    var gigametre = MeasureStandardPrefix.Giga[MeasureUnit.Metre];
    gigametre.Abbreviation.Should().Be( "Gm" );
    gigametre.Name.Should().Be( "Gigametre" );

    var decigigametre = MeasureStandardPrefix.Deci[gigametre];
    decigigametre.Abbreviation.Should().Be( "(10^-1)Gm" );
    decigigametre.Name.Should().Be( "(10^-1)Gigametre" );


    // Instead of "(10^-2)Gigametre", we always try to minimize the absolute value
    // of the adjustment factor: here we generate the "(10^1)Megametre".
    
    var decidecigigametre = MeasureStandardPrefix.Deci[decigigametre];

    decidecigigametre.Abbreviation.Should().Be( "(10^1)Mm" );
    decidecigigametre.Name.Should().Be( "(10^1)Megametre" );


    var decidecidecigigametre = MeasureStandardPrefix.Deci[decidecigigametre];
    decidecidecigigametre.Abbreviation.Should().Be( "Mm" );
    decidecidecigigametre.Name.Should().Be( "Megametre" );
}

 

[Test]
public void out_of_bounds_adjustment_factors()
{
    var yottametre = MeasureStandardPrefix.Yotta[MeasureUnit.Metre];
    var lotOfMetre = MeasureStandardPrefix.Hecto[yottametre];
    lotOfMetre.Abbreviation.Should().Be( "(10^2)Ym" );

    var evenMore = MeasureStandardPrefix.Deca[lotOfMetre];
    evenMore.Abbreviation.Should().Be( "(10^3)Ym" );


    var backToReality = MeasureStandardPrefix.Yocto[evenMore];
    backToReality.Abbreviation.Should().Be( "km" );

    var belowTheAtom = MeasureStandardPrefix.Yocto[backToReality];
    belowTheAtom.Abbreviation.Should().Be( "zm" );

    belowTheAtom.Name.Should().Be( "Zeptometre", "The Zeptometre is 10^-21 metre." );
    var decizettametre = MeasureStandardPrefix.Deci[belowTheAtom];

    decizettametre.Abbreviation.Should().Be( "(10^-1)zm" );
    var decidecizettametre = MeasureStandardPrefix.Deci[decizettametre];

    decidecizettametre.Abbreviation.Should().Be( "(10^1)ym" );
    var yoctometre = MeasureStandardPrefix.Deci[decidecizettametre];

    yoctometre.Abbreviation.Should().Be( "ym" );

    var below1 = MeasureStandardPrefix.Deci[yoctometre];
    below1.Abbreviation.Should().Be( "(10^-1)ym" );


    var below2 = MeasureStandardPrefix.Deci[below1];
    below2.Abbreviation.Should().Be( "(10^-2)ym" );

}

第三天:处理别名

现在是时候处理别名,并最终找到一种方法来规范化单位,以便我们能够实际使用它们来计算数量。

千克例外和字节

目前,没有办法处理克,因为目前克是毫千克:千克是官方的标准重量单位。你不想看到“mkg”这个单位!

为“mkg”引入一个别名(作为“g”/”Gram”)将意味着在太多地方处理这个例外。更容易的做法是“作弊”,将Gram定义为基本单位而不是KilogramKilogram现在在MeasureUnit类型初始化器中定义为PrefixedMeasureUnit(并且仍然作为static属性公开)。

/// <summary>
/// The kilogram is the unit of mass; it is equal to the mass of the international
/// prototype of the kilogram.
/// This is the only SI base unit that includes a prefix. To avoid coping with this
/// exception in the code, we
/// define it as a PrefixedMeasureUnit based on the gram (MeasureStandardPrefix.Kilo
/// of Gram).
/// Associated abbreviation is "kg".
/// </summary>
public static readonly PrefixedMeasureUnit Kilogram;

/// <summary>
/// The gram is our fundamental unit of mass (see Kilogram).
/// Associated abbreviation is "g".
/// </summary>
public static readonly FundamentalMeasureUnit Gram;

Type初始化器(static private构造函数)中:

Kilogram = RegisterPrefixed( ExpFactor.Neutral, MeasureStandardPrefix.Kilo, Gram );

Byte也作为MeasureUnit类上的static字段公开。它使用新的AliasMeasureUnit类,该类现在使我们能够从其他单位创建新命名的单位。

/// <summary>
/// A byte is now standardized as eight bits, as documented in ISO/IEC 2382-1:1993.
/// The international standard IEC 80000-13 codified this common meaning.
/// Associated abbreviation is "B" and it is an alias with a ExpFactor 2^3 on Bit.
/// </summary>
public static readonly AliasMeasureUnit Byte;

Byte = new AliasMeasureUnit( "B", "Byte", new FullFactor( new ExpFactor(3,0) ), Bit );

定义新单位

新的别名和原始的基本单位是显式定义新单位的两种方式。

/// <summary>
/// Defines an alias.
/// The same alias can be registered multiple times but it has to exactly match the
/// previously registered one.
/// </summary>
/// <param name="abbreviation">
/// The unit of measure abbreviation.
/// This is the key that is used. It must not be null or empty.
/// </param>
/// <param name="name">The full name. Must not be null or empty.</param>
/// <param name="definitionFactor">
/// The factor that applies to the AliasMeasureUnit.Definition. Must not be
/// FullFactor.Zero.
/// </param>
/// <param name="definition">The definition. Can be any CombinedMeasureUnit.</param>
/// <returns>The alias unit of measure.</returns>
public static AliasMeasureUnit DefineAlias(
     string abbreviation,
     string name,
     FullFactor definitionFactor,
     CombinedMeasureUnit definition ) { … }

 

/// <summary>
/// Define a new fundamental unit of measure (or returns the already defined one).
/// Just like DefineAlias, the same fundamental unit can be redefined multiple times
/// as long as it is actually the same: for fundamental units, the (long) name
/// must be exactly the same.
/// </summary>
/// <param name="abbreviation">
/// The unit of measure abbreviation.
/// This is the key that is used. It must not be null or empty.
/// </param>
/// <param name="name">The full name. Must not be null or empty.</param>
/// <returns>The fundamental unit of measure.</returns>
public static FundamentalMeasureUnit DefineFundamental( string abbreviation, string name ) { … }

DefineAlias中出现的FullFactor是对前面描述的ExpFactor的一个简单扩展,它在ExpFactor中添加了一个简单的Factor字段。目前我们使用double,但我们应该在这里使用一个有理数,比如https://nuget.net.cn/packages/Rationals/提供的(这似乎是一个不错的项目)。

FullFactor描述了定义单位的简单线性调整。有了它,我们现在可以定义牛顿、达因和千克力(来自https://en.wikipedia.org/wiki/Newton_(unit))。

  • 牛顿(N):其定义直接使用基本单位:1 N = 1 kg.m.s^-2
  • 达因(dyn)定义为:1 dyn = 10^-5 N
  • 千克力(kp)是:1 kp = 9.80665 N
using static CK.Core.MeasureUnit;

…

var kg = Kilogram;
var m = Metre;
var s = Second;

var newton = DefineAlias( "N", "Newton", FullFactor.Neutral, kg * m * (s ^ -2) );
var dyne = DefineAlias( "dyn", "Dyne", new ExpFactor( 0, -5 ), newton );
var kilopound = DefineAlias( "kp", "Kilopound", 9.80665, newton );
var newtonPerDyne = newton / dyne;
newtonPerDyne.Abbreviation.Should().Be( "N.dyn-1" );

注意

  • 为了缩短上面的代码,我们使用了using static CK.Core.MeasureUnit
  • FullFactor定义了从doubleExpFactor的隐式转换运算符。我们可以以更简单的方式定义牛顿:
var newton = DefineAlias( "N", "Newton", 1.0, kg * m * (s ^ -2) );

到目前为止一切顺利……但是……“N.dyn-1”的实际单位是什么?

你可能会惊讶,但它可能是“弧度”(或“球面度”),因为这实际上没有单位:它就是Measure.None(就像弧度和球面度不是实际单位一样)。

现在我们可以使用这些单位来*计算*数量了!

第四天:单位规范化,开始计算

Quantity(数量)就是一个数值(intfloatdouble、有理数、大整数等)与其单位的关联。

本库的目标之一是帮助计算这样的数量,例如:

上面r的值是2.10^5。对我们来说,就是2.10^5 MeasureUnit.None:N.dyn-1必须解析为无量纲单位*以及*连接它们的因子。

在操作数量之前,它们必须首先被提升/对齐到相同的单位度量。你可以随时乘以/除以数量(无论它们的量纲是什么),只需创建结果的量纲:10 m x 40 min = 400 m.min。

但是,要加或减2个数量,它们必须具有完全相同的单位:2 m² + 3 m² = 5m² 或 2 m² + 3 cm²,这完全有效,等于20003 cm²(或你喜欢的2.0003 m²)。

在解决这个问题之前,我们需要进行一些重构。当前模型将MeasureUnit作为所有单位的基类abstract类。然而,唯一的实际特化是CombinedMeasureUnit,并且看起来这就是所需的一切(第一天并不明显,当时这种间接方式似乎有用)。我们现在可以通过将MeasureUnit视为始终是CombinedMeasureUnit来简化模型。后者已消失,合并到MeasureUnit(它变得更大),并且为了保持代码整洁,它被分割成部分文件。

最终的类图是:

图:最终模型

我们可以合并ExponentAtomic,但我们没有这样做,也不想这样做。原因有二:

1 - 目前(见第一天)不可能,我们想保持这一点。

    var decimetreCube = decimetre ^ 3;
    decimetreCube.Abbreviation.Should().Be( "dm3" );
    decimetreCube.Name.Should().Be( "Decimetre^3" );

    // This does'nt compile and this is perfect! :)
    //var notPossible = MeasureStandardPrefix.Deci.On( decimetreCube );

2 - 这将使代码“类型安全”性降低,因此编写和理解起来会更“复杂”。

重构完成。让我们回到今天的任务:规范化。

这里有一个明显的选择:FundamentalMeasureUnit本身就是每一个单量纲单位的规范形式。当涉及多个维度时,有一个复合MeasureUnit,它只包含原子(或其幂)的FundamentalMeasureUnit(即不再有别名或带前缀的单位)。

从任何MeasureUnit到其规范形式,我们可以(一次性)计算出将单位映射到它的FullFactor

引入一个新的类对此是没用的。将这添加到我们的模型中最简单的方法是向MeasureUnit基类添加3个属性:

/// <summary>
/// Gets whether this MeasureUnits only contains normalized units.
/// </summary>
public bool IsNormalized { get; }

/// <summary>
/// Gets the factor that must be applied from this measure to its Normalization.
/// </summary>
public FullFactor NormalizationFactor { get; }

/// <summary>
/// Gets the canonical form of this measure.
/// Its IsNormalized property is necessarily true.
/// </summary>
public MeasureUnit Normalization { get; }

它有效!

下面的测试展示了dm²和cm²到m²的转换,证明了m.s⁻²(米每二次方秒,即加速度)与μm.ms⁻²(微米每毫秒平方)相同,并且1 m/s 大约等于 0.277778 km/h!

[Test]
public void basic_normalization_with_prefix()
{
    var metre = MeasureUnit.Metre;
    var squaredMeter = metre ^ 2;
    squaredMeter.Normalization.Should().Be( squaredMeter );

    var decimeter = MeasureStandardPrefix.Deci[metre];
    var squaredDecimeter = decimeter ^ 2;
    squaredDecimeter.Normalization.Should().Be( squaredMeter );
    squaredDecimeter.NormalizationFactor
                  .Should().Be( new FullFactor( new ExpFactor( 0, -2 ) ), "1 dm2 = 10-2 m2"  );

    var centimeter = MeasureStandardPrefix.Centi[metre];
    var squaredCentimeter = centimeter ^ 2;
    squaredCentimeter.Normalization.Should().Be( squaredMeter );
    squaredCentimeter.NormalizationFactor
                 .Should().Be( new FullFactor( new ExpFactor( 0, -4 ) ), "1 cm2 = 10-4 m2" );
}
[Test]
public void equivalent_combined_units()
{
    var metre = MeasureUnit.Metre;
    var second = MeasureUnit.Second;

    var acceleration = metre / (second ^ 2);
    acceleration.IsNormalized.Should().BeTrue();

    var micrometre = MeasureStandardPrefix.Micro[metre];
    var millisecond = MeasureStandardPrefix.Milli[second];

    var acceleration2 = micrometre / (millisecond ^ 2);
    acceleration2.IsNormalized.Should().BeFalse();
    acceleration2.Normalization.Should().BeSameAs( acceleration );
    acceleration2.NormalizationFactor.Should().Be( FullFactor.Neutral );

}
[Test]
public void combined_units_with_factor()
{
    var metre = MeasureUnit.Metre;
    var second = MeasureUnit.Second;
    var speed = metre / second;
    speed.IsNormalized.Should().BeTrue();

    var kilometre = MeasureStandardPrefix.Kilo[metre];
    var hour = MeasureUnit.DefineAlias( "h", "Hour", 60*60, second );
    var speed2 = kilometre / hour;
    speed2.IsNormalized.Should().BeFalse();

    speed2.Normalization.Should().BeSameAs( speed );
    speed2.NormalizationFactor.ToDouble()
           .Should().BeApproximately( 0.2777777778, 1e-10, "1 m/s = 0.277778 km/h" );
}

我们今天没有时间详细说明实现这种“魔术”(真正神奇的是它多么简洁 😊)的(极简)代码。我们明天再谈。

第五天:数量

昨天,在实现规范化时,我意识到将规范化/标准单位的概念与最初认为是同一事物的FundamentalMeasureUnit分离开来相当容易。有了这个,*千克例外*不再是例外,而且最好的消息是,当定义基本单位时,这个特性现在也可用了。

/// <summary>
/// Define a new fundamental unit of measure.
/// Just like DefineAlias, the same fundamental unit can be redefined multiple times
//// as long as it is actually the same: for fundamental units, the Name (and the
/// normalizedPrefix if any) must be exactly the same.
/// </summary>
/// <param name="abbreviation">
/// The unit of measure abbreviation.
/// This is the key that is used. It must not be null or empty.
/// </param>
/// <param name="name">The full name. Must not be null or empty.</param>
/// <param name="normalizedPrefix">
/// Optional prefix to be used for units where the normalized unit should not be the 
/// FundamentalMeasureUnit but one of its PrefixedMeasureUnit. 
/// This is the case for the "g"/"Gram" and the "kg"/"Kilogram".
/// Defaults to MeasureStandardPrefix.None: by default a fundamental unit is the
/// normalized one.
/// </param>
/// <returns>The fundamental unit of measure.</returns>
public static FundamentalMeasureUnit DefineFundamental(
                  string abbreviation,
                  string name,
                  MeasureStandardPrefix normalizedPrefix = null )

这种新的规范化行为现在符合SI标准。

var metre = MeasureUnit.Metre;
var second = MeasureUnit.Second;
var kilogram = MeasureUnit.Kilogram;
var newton = kilogram * metre * (second ^ -2);
newton.Abbreviation.Should().Be( "kg.m.s-2" );


// With FundamentalMeasureUnit as the only normalized form:
// newton.Normalization.Abbreviation.Should().Be( "g.m.s-2" );
// newton.NormalizationFactor.Should().Be( new FullFactor( new ExpFactor( 0, 3 ) ) );

// A PrefixedMeasureUnit can be the normalized form:
newton.Normalization.Abbreviation.Should().Be( "kg.m.s-2" );
newton.NormalizationFactor.Should().Be( FullFactor.Neutral );



是时候解释规范化代码了。实际的规范化形式是按需计算的,如果单位不是(按构造)定义为*规范化*形式,并且尚未计算过,则会计算。

public bool IsNormalized => _normalization == this;

public FullFactor NormalizationFactor
{
    get
    {
        if( _normalization == null )
        {
            (_normalization, _normalizationFactor) = GetNormalization();
        }
        return _normalizationFactor;
    }
}


public MeasureUnit Normalization
{
    get
    {
        if( _normalization == null )
        {
            (_normalization, _normalizationFactor) = GetNormalization();
        }
        return _normalization;
    }
}

我们在这里使用值元组来返回因子和规范化单位。在MeasureUnit的顶层,潜在的多个单位的规范化形式被组合成一个列表,进行去重。同时,通过将所有规范化因子与初始中性因子相乘来计算最终的规范化因子。

Combinator是一个小的private struct,它封装了去重。它在第一天开发(自创建以来进行了一些重构),而且相当简单(代码可以在这里找到:https://github.com/Invenietis/CK-UnitsOfMeasure/blob/master/CK.UnitsOfMeasure/MeasureUnit.Combinator.cs)。

private protected virtual (MeasureUnit, FullFactor) GetNormalization()
{
    Combinator measures = new Combinator( null );
    var f = _units.Aggregate( FullFactor.Neutral, ( acc, m ) =>
    {
        measures.Add( m.Normalization.MeasureUnits );
        return acc.Multiply( m.NormalizationFactor );
    } );
    return (measures.GetResult(), f);
}

这是组合单位的通用实现。Exponent和Atomic单位会覆盖此行为,这在我看来是良好老式标准面向对象范例之美的体现。

ExponentMeasureUnit的规范化形式是其原子单位的规范化形式的幂。其规范化因子也是其原子单位的幂。

private protected override (MeasureUnit, FullFactor) GetNormalization()
{
    return (
             AtomicMeasureUnit.Normalization.Power( Exponent ),
             AtomicMeasureUnit.NormalizationFactor.Power( Exponent )
           );
}

对于AliasMeasureUnit,它甚至更简单:其规范化形式是其定义之一,其规范化因子乘以其自身的DefinitionFactor

private protected override (MeasureUnit, FullFactor) GetNormalization()
{
    return (
             Definition.Normalization,
             Definition.NormalizationFactor.Multiply( DefinitionFactor )
           );
}

最后,PrefixedMeasureUnit通过应用其前缀因子和调整因子来计算其规范化因子(调整因子很好地处理了像“DeciGiga”这样的“愚蠢”前缀 - 参见第二天)。

private protected override (MeasureUnit, FullFactor) GetNormalization()
{
    return (
             AtomicMeasureUnit.Normalization,
             AtomicMeasureUnit.NormalizationFactor
                              .Multiply( Prefix.Factor )
                              .Multiply( AdjustmentFactor )
           );
}

就是这样!

你可能会问:“线程安全呢?这看起来完全不线程安全!”

这完全是线程安全的(整个库都是完全线程安全的)。但由于这可能是一个冗长的讨论,我稍后会谈。我很高兴开始实现数量……

Quantity是一个简单的不可变值类型,它只是结合了一个double值和一个单位。对数量的操作很容易实现。以下是Quantity类型的核心:

public struct Quantity
{
    public readonly double Value;

    public readonly MeasureUnit Unit;

    public Quantity( double v, MeasureUnit u )
    {
        Value = v;
        Unit = u;
    }


    public Quantity Multiply( Quantity q ) => new Quantity( Value * q.Value, Unit * q.Unit );

    public Quantity DivideBy( Quantity q ) => new Quantity( Value / q.Value, Unit / q.Unit );

    public Quantity Invert() => new Quantity( 1.0 / Value, Unit.Invert() );

    public Quantity Power( int exp ) => 
               new Quantity( Math.Pow( Value, exp ), Unit.Power( exp ) );

    public bool CanConvertTo( MeasureUnit u ) => Unit.Normalization == u.Normalization;


    public Quantity ConvertTo( MeasureUnit u )
    {
        if( !CanConvertTo( u ) )
        {
            throw new ArgumentException( $"Can not convert from '{Unit}' to '{u}'." );
        }
        FullFactor ratio = Unit.NormalizationFactor.DivideBy( u.NormalizationFactor );
        return new Quantity( Value * ratio.ToDouble(), u );
    }

    public static Quantity operator /( Quantity o1, Quantity o2 ) => o1.DivideBy( o2 );

    public static Quantity operator *( Quantity o1, Quantity o2 ) => o1.Multiply( o2 );

    public static Quantity operator ^( Quantity o, int exp ) => o.Power( exp );

    public string ToString( IFormatProvider formatProvider ) => 
                                         Value.ToString( formatProvider ) 
                                                                + " " + Unit.ToString();

    public override string ToString() => $"{Value} {Unit}";

}

通过intdouble上的WithUnit扩展方法(我通常避免用扩展方法污染基本类型,但在这里我认为它很有意义),它就可以工作了。

[Test]
public void simple_operations()
{
    var metre = MeasureUnit.Metre;
    var second = MeasureUnit.Second;
    var kilometre = MeasureStandardPrefix.Kilo[metre];
    var minute = MeasureUnit.DefineAlias( "min", "Minute", 60, second );
    var hour = MeasureUnit.DefineAlias( "h", "Hour", 60, minute );
    var speed = kilometre / hour;

    var myDistance = 3.WithUnit( kilometre );
    var mySpeed = 6.WithUnit( speed );
    var myTime = myDistance / mySpeed;

    myTime.ToString( CultureInfo.InvariantCulture ).Should().Be( "0.5 h" );

    myTime.CanConvertTo( minute ).Should().BeTrue();
    myTime.ConvertTo( minute ).ToString().Should().Be( "30 min" );
    myTime.CanConvertTo( second ).Should().BeTrue();
    myTime.ConvertTo( second ).ToString().Should().Be( "1800 s" );
}

为了好玩(以及更多测试),让我们根据“诗意单位”来检查一下。谷歌单位转换器说:

我选择美国度量单位,因为它们由英寸定义(美国加仑定义为231立方英寸),所以对我们来说比英制加仑更有趣,英制加仑直接与米相关。

英寸(当然)是2.54厘米。美国英里现在是国际英里,即1.609344公里。但要小心,美国、英国和其他国家有许多不同的英里(参见https://en.wikipedia.org/wiki/Mile)。

对我们法国人(顺便说一句,现代SI制也受到我们的启发)来说,这是纯粹的诗歌。

public void poetic_units()
{
    var metre = MeasureUnit.Metre;
    var decimetre = MeasureStandardPrefix.Deci[metre];
    var centimetre = MeasureStandardPrefix.Centi[metre];
    var kilometre = MeasureStandardPrefix.Kilo[metre];
    var hundredKilometre = MeasureStandardPrefix.Hecto[kilometre];
    var litre = decimetre ^ 3;

    var inch = MeasureUnit.DefineAlias( "in", "Inch", 2.54, centimetre );
    var gallon = MeasureUnit.DefineAlias( "gal", "US Gallon", 231, inch ^ 3 );

    var mile = MeasureUnit.DefineAlias( "mile", "Mile", 1.609344, kilometre );
    var milesPerGalon = mile / gallon;
    var litrePerHundredKilometre = litre / hundredKilometre;
    var oneMilesPerGallon = 1.WithUnit( milesPerGalon );

    oneMilesPerGallon.CanConvertTo( litrePerHundredKilometre ).Should().BeTrue();
    var result = oneMilesPerGallon.ConvertTo( litrePerHundredKilometre );
    result.Value.Should().BeApproximately( 235.215, 1e-3 );
}

这失败了!

oneMilesPerGallon.CanConvertTo( litrePerHundredKilometre)false……仅仅因为这两个单位是倒置的。

  • 英里/加仑 ≡ 距离 / 距离³ ≡ 距离²
  • 升/公里 ≡ 距离³ / 距离 ≡ 距离⁻²。

我们可以处理这一点(并使我们的库更智能一些)。

public bool CanConvertTo( MeasureUnit u ) => Unit.Normalization == u.Normalization
                                             || Unit.Normalization == u.Normalization.Invert();


public Quantity ConvertTo( MeasureUnit u )
{
    if( !CanConvertTo( u ) )
    {
        throw new ArgumentException( $"Can not convert from '{Unit}' to '{u}'." );
    }
    if( Unit.Normalization == u.Normalization )
    {
        FullFactor ratio = Unit.NormalizationFactor.DivideBy( u.NormalizationFactor );
        return new Quantity( Value * ratio.ToDouble(), u );
    }
    else
    {
        FullFactor ratio = Unit.NormalizationFactor.Multiply( u.NormalizationFactor );
        return new Quantity( 1/(Value * ratio.ToDouble()), u );
    }
}

这样就可以工作了!

在实现第一个数量测试时,我(为了稍微复杂化测试)使用了以下小时的定义:

    var minute = MeasureUnit.DefineAlias( "min", "Minute", 60, second );
    var hour = MeasureUnit.DefineAlias( "h", "Hour", 60, minute );

昨天的测试使用了:

    var hour = MeasureUnit.DefineAlias( "h", "Hour", 60 * 60, second );

测试现在失败了:第一个执行注册其小时的定义,第二个重新定义被(正确地)检测为不相同。“罪魁祸首”在这里(内部代码):

static AliasMeasureUnit RegisterAlias(string a, string n, FullFactor f, MeasureUnit d)
{
    return Register( abbreviation: a,
                     name: n,
                     creator: () => new AliasMeasureUnit( a, n, f, d ),
                     checker: m => m.DefinitionFactor == f && m.Definition == d
     );
}

最后一个参数是“检查器”:一个函数,Register核心函数会调用它来检查实际注册的度量是否“相同”(这样你就不能定义具有相同缩写的不同度量单位——缩写是键)。

上面定义的两个“小时”是相同的还是不相同的?

不幸的是,这取决于你的业务需求。然而,通过挑战(而不是精确定义)它的规范化,可以很容易地放宽检查。

    checker: m => m.Definition.Normalization == d.Normalization
                  && m.NormalizationFactor == d.NormalizationFactor.Multiply( f ) );

我们应该实现严格的还是宽松的检查?再次,这取决于你的业务需求。

在开发一个旨在用于不同上下文的库时,我的建议是:在做这种突然的决定时要非常谨慎,我总是尽量*不*将这种行为/选择深深地固定在代码中(而库开发中困难的部分是识别这些选择)。

在处理这个问题之前,我想重构一下代码。它工作得很好,但据我所知,当前架构有一个巨大的问题:度量单位只有一个、全局的上下文。如果你花时间阅读这个https://en.wikipedia.org/wiki/Gallon 或这个https://en.wikipedia.org/wiki/Ouncehttps://en.wikipedia.org/wiki/Ton-force,你能想象一个可怕的单例能在被不同客户使用或处理来自不同领域度量单位的Web应用程序中工作吗?

“是的……但是单例太容易使用了!”

正确。重构将保留当前API:它将是默认度量上下文。但是你将能够创建任意数量独立的MeasureContext(其中一些可能不包含Metre/Gram等默认度量单位)。

……重构完成。MeasureUnit暴露的static“易于使用”的标准单位现在只是StandardMeasureContext.Default单例的代理。

/// <summary>
/// Dimensionless unit. Used to count items. Associated abbreviation is "#".
/// </summary>
public static FundamentalMeasureUnit Unit => StandardMeasureContext.Default.Unit;

当混合来自不同上下文的度量时,会引发一个异常:

[Test]
public void measures_from_different_contexts_must_not_interfere()
{
    var kilogram = MeasureUnit.Kilogram;
    kilogram.Should().BeSameAs( StandardMeasureContext.Default.Kilogram );

    var another = new StandardMeasureContext();
    var anotherKilogram = another.Kilogram;
    anotherKilogram.Should().NotBeSameAs( kilogram );
    another.Invoking( c => c.DefineAlias( "derived", "Derived", 2, kilogram)).Should()
        .Throw<Exception>()
        .Where( ex => ex.Message.Contains( "Context mismatch" ) );

    StandardMeasureContext.Default
         .Invoking( c => c.DefineAlias( "derived", "Derived", 2, anotherKilogram ).Should()
         .Throw<Exception>()
         .Where( ex => ex.Message.Contains( "Context mismatch" ) );

    Action a = () => Console.WriteLine( kilogram / anotherKilogram );
    a.Should().Throw<Exception>()
              .Where( ex => ex.Message.Contains( "Context mismatch" ) );

}

之前的API没有改变,但现在你可以创建独立的MeasureContext,除了“None”之外没有单位,或者使用StandardMeasureContext,它公开并已定义UnitBitByte和7个SI单位。

今天就到这里。

第六天 – 数量的全面展现

我以增强Quantity的运算符开始这一天,因此现在支持:

[Test]
public void Quantity_operators_override()
{
    var d1 = 1.WithUnit( MeasureUnit.Metre );
    var d2 = 2.WithUnit( MeasureUnit.Metre );
    var d3 = d1 + d2;
    d3.Value.Should().Be( 3.0 );

    var d6 = d3 * 2;
    d6.Value.Should().Be( 6.0 );

    var s36 = d6 ^ 2;
    s36.Value.Should().Be( 36.0 );
    s36.Unit.Should().Be( MeasureUnit.Metre * MeasureUnit.Metre );

    var sM36 = -s36;
    sM36.Value.Should().Be( -36.0 );
    sM36.Unit.Should().Be( MeasureUnit.Metre * MeasureUnit.Metre );

    (s36 - sM36).Value.Should().Be( 72.0 );
    (s36 + sM36).Value.Should().Be( 0.0 );

    var s9 = d3 * d3;

    s9.Value.Should().Be( 9.0 );
    s9.Unit.Should().Be( MeasureUnit.Metre * MeasureUnit.Metre );

    var r4 = s36 / s9;
    r4.Value.Should().Be( 4 );
    r4.Unit.Should().Be( MeasureUnit.None );

    var s144 = r4 * s36;
    s144.Value.Should().Be( 144.0 );
    s144.Unit.Should().Be( MeasureUnit.Metre * MeasureUnit.Metre );

    var v27 = d3 ^ 3;
    v27.Value.Should().Be( 27.0 );
    v27.Unit.Should().Be( MeasureUnit.Metre ^ 3 );

    (v27 / s9).Should().Be( d3 );
    ((v27 / s9) == d3).Should().BeTrue();
    ((v27 / s9) != d3).Should().BeFalse();
}

然后我遇到了一个……问题。任何C#/Java/...开发者都知道,每当你定义Equals(这里实现IEquatable<Quantity> 接口)时,都应该重写Equals(object)GetHashCode()方法。

首先,我们需要定义数量的相等性。

  • 它们必须可以互相转换单位。
  • 当转换到其中一个(或另一个)单位时,它们的值必须相同。

有了这个,“10 dm”(分米)将等于“1 m”“0.1 hm”(赫米),“0.001 km”等等。

到目前为止一切顺利,实现相等性意味着将另一个数量转换到当前数量的单位并检查结果值。当然,如果另一个数量无法转换,则两个数量不相等。

public bool Equals( Quantity other ) => other.CanConvertTo( Unit ) 
                                        && other.ConvertTo( Unit ).Value == Value;

然后在对象层面:

public override bool Equals( object obj ) => obj is Quantity q && Equals( q );

然后是GetHashCode……我们这里没有“其他”。我们必须计算一个哈希码,它必须符合Equal规则,但又要“独立”。解决方案是使用Unit.NormalizationNormalizationFactor作为“目标”数量。

public override int GetHashCode() => ConvertTo( Unit.Normalization ).Value.GetHashCode();

这应该有效……但实际上不行。这个测试极其失败。

[Test]
public void Quantity_with_alias_and_prefixed_units()
{
    var metre = MeasureUnit.Metre;
    var decametre = MeasureStandardPrefix.Deca[metre];
    var decimetre = MeasureStandardPrefix.Deci[metre];

    var dm1 = 1.WithUnit( decimetre );
    var dam1 = 1.WithUnit( decametre );
    var dm101 = dm1 + dam1;
    var dam1Dot01 = dam1 + dm1;

    dm101.ToString( CultureInfo.InvariantCulture ).Should().Be( "101 dm" );
    dam1Dot01.ToString( CultureInfo.InvariantCulture ).Should().Be( "1.01 dam" );

    dm101.Equals( dam1Dot01 ).Should().BeTrue();
    dm101.GetHashCode().Should().Be( dam1Dot01.GetHashCode() );

}

什么?怎么回事?有什么想法吗?

欢迎来到浮点数地狱!

两个double的哈希码略有不同。它们的值也不同。

这种情况会发生,但有趣的是:

  • 两个之间的转换给出精确值(双向)。
  • 转换为它们的公共规范单位(米)时,以string表示也相同。
dm101.ConvertTo( metre ).ToString().Should().Be( "10.1 m" );
dam1Dot01.ConvertTo( metre ).ToString().Should().Be( "10.1 m" );

后者是由于double.ToString()实现“巧妙地”通过四舍五入/清理值来优化其输出。

有关浮点数问题的更多信息,请随时参考这篇优秀的论文:https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html

我们在这里有哪些选择?

  • 忘记哈希码
    • 并保留Equals
      • 任何使用Quantity作为键的字典都会严重失败。
    • 同时忘记Equals
      • 这很奇怪。人们期望一个名为“Quantity”的值类型支持相等性。
  • 保留当前的Equals实现并提供哈希码
    • 如当前实现所示。
      • 任何使用Quantity作为键的字典都会严重失败。
    • 通过返回0
      • 这是完全有效的(不会引入任何错误),但将此类对象用作字典的键将严重降低性能(从O(1)到O(n))。
    • 通过调用Math.Roundhttps://docs.microsoft.com/en-us/dotnet/api/system.math.round)转换后的结果。
      • 什么样的四舍五入?精度如何?我不知道。
        GetHashCode()是一个必须快速运行的方法。仅转换就已耗时,添加后续的四舍五入可能不是个好主意。
  • ► 寻找另一种EqualsGetHashCode
    • 对象相等性必须是“一致的”,遵循“最小惊讶原则”,不一定“精确”(双精度数“精确”是什么意思?)。

想法是简单地使用规范化数量的字符串表示。两个数量相等当且仅当它们的规范化表示相同时。NormalizedString现在暴露在Quantity上,因为它是一个非常实用且有用的属性。

我选择存储字符串表示以避免每次需要时重新计算,并且它是惰性初始化的。Quantity struct现在有点重(一个double - 8字节,和2个对象引用 - 2 x 4或8字节),这是要付出的代价。

惰性初始化不保护并发访问(带有昂贵的锁甚至Compare-And-Swap - Interlocked指令):最糟糕的情况是我们可能会计算_normalized字段两次。

public string ToNormalizedString()
{
    if( _normalized == null )
    {
        _normalized = ConvertTo( Unit.Normalization ).ToString( CultureInfo.InvariantCulture );
    }
    return _normalized;
}

public override int GetHashCode() => ToNormalizedString().GetHashCode();

public bool Equals( Quantity other ) => ToNormalizedString() == other.ToNormalizedString();

相等性问题已解决。但还有两件事让我烦恼:

  • 默认的struct Quantity - new Quantity() - 有一个0.0值和一个null Unit。这个null数量Unit目前没有处理,这是一个定时炸弹。
  • 可比性:Quantity应该可比吗?不幸的是,是的,就像可相等性一样。

 

第七天 – 最终确定数量

默认struct new Quantity()中的null Unit已得到处理。即使这个默认数量(0,null)是可能出现null MeasureUnit的唯一情况,我也借此机会处理了整个API中的null单位。

现在,null单位在逻辑上等同于MeasureUnit.None单位(无量纲单位)。这个特殊的单位现在是一个唯一的、无上下文的单位(一个真正的单例),由所有MeasureContext共享。这对当前代码完全没有影响,除了现在接受使用null单位。

[Test]
public void handling_null_MeasureUnit_as_None()
{
    MeasureUnit theNull = null;
    var kiloM1 = theNull / MeasureUnit.Kilogram;
    kiloM1.Abbreviation.Should().Be( "kg-1" );

    var kiloM1Bis = kiloM1 / theNull;
    kiloM1Bis.Should().BeSameAs( kiloM1 );

    (theNull * kiloM1).Should().BeSameAs( kiloM1 );
    (kiloM1 * theNull * theNull * theNull).Should().BeSameAs( kiloM1 );

    var none = theNull / theNull;
    none.Should().BeSameAs( MeasureUnit.None );

    var none2 = theNull * theNull;
    none2.Should().BeSameAs( MeasureUnit.None );

}

默认的new Quantity()现在与绑定到None单位时的行为完全相同。

[Test]
public void Quantity_kindly_handle_the_default_quantity_with_null_Unit()
{
    var qDef = new Quantity();
    qDef.ToNormalizedString().Should().Be( "0" );
    qDef.CanConvertTo( MeasureUnit.None ).Should().BeTrue();

    var kilo = 1.WithUnit( MeasureUnit.Kilogram );
    qDef.CanConvertTo( kilo.Unit ).Should().BeFalse();
    qDef.CanAdd( kilo ).Should().BeFalse();

    kilo.CanAdd( qDef ).Should().BeFalse();
    var zeroKilo = qDef.Multiply( kilo );
    zeroKilo.ToNormalizedString().Should().Be( "0 kg" );

    (qDef * kilo).ToNormalizedString().Should().Be( "0 kg" );
    (kilo * qDef).ToNormalizedString().Should().Be( "0 kg" );
    (kilo.Multiply( qDef)).ToNormalizedString().Should().Be( "0 kg" );
    (qDef / kilo).ToNormalizedString().Should().Be( "0 kg-1" );

    var qDef2 = qDef.ConvertTo( MeasureUnit.None );
    qDef2.ToNormalizedString().Should().Be( "0" );
}

这是昨天的,下午晚些时候。

今天早上,我对这种null处理感到有点害怕。为了保护*一个*情况,null单位现在被透明地接受为None的有效同义词……然而null不是None

  • theNull.Abbreviation,
  • theNull.Name,
  • theNull.Divide()等。

将抛出null引用异常。除非我们为每一个方面使用扩展方法(它们会进行保护并转发到内部实现),否则这只会导致bug工厂…

回滚!

只保留None单位单例,我们现在只保护/检查Quantity.Unit属性。

public MeasureUnit Unit => _unit ?? MeasureUnit.None;

上面第一个handling_null_MeasureUnit_as_None测试已被删除,只剩下第二个。

完成此操作后,让我们对数量进行排序。首先,我们必须修复Quantity的相等性支持,引入单位必须属于同一上下文的事实。

public override int GetHashCode() => Unit.Normalization.GetHashCode() 
                                     ^ ToNormalizedString().GetHashCode();

public bool Equals( Quantity other ) => Unit.Context == other.Unit.Context
                                        && ToNormalizedString() == other.ToNormalizedString();

然后我们必须决定两个不相关数量(不共享相同的规范化单位,因此实际上不可比)如何相互比较。这不可能是现实的,它只需要是确定性和稳定的。

  • 在同一上下文内,两个不相关数量将使用其各自单位的规范化缩写。多亏了这一点,当对一组数量进行排序时,所有绑定到相同维度的数量都将被分组在一起。
  • 跨上下文,我们需要一种方法来排序上下文,但目前还没有。我们给上下文添加了一个Name。默认上下文有一个空字符串Name,所有其他上下文都必须使用名称构造。多亏了这一点,绑定到不同上下文单位的数量就可以进行比较了。

混合来自不同上下文的单位实际上不应该发生。上下文的概念是本库的一个可选功能,在某些场景下(通常涉及多租户和/或持久化数据)是相关的。本库不包含上下文名称的注册表或其他中央字典:上下文在使用时,用于隔离单位,因此应单独使用。

QuantityCompareTo和比较运算符可用。顺便说一句,一个可怕的bug在Prefixed规范化单位(对于“kg/”g”例外)上已得到修复。最后一步是将库及其命名空间重命名为“CK.UnitsOfMeasure”,并在其中设置构建链。

第八天:解析:单位的语法

解析单位并不困难,因为底层的语法已经确立,这是之前采用的命名规则的结果。

实现的解析在删除所有空格后使用正则表达式。输入语法不严格,我们“规范化”结果。

  • 任何单位的零指数都导致None:“kg0” ► “” (None)
  • 调整因子被转换为最佳前缀:“(10^-3)m” ► “mm”
  • 允许使用多个调整因子,并自动计算。

    “(10^-8*10^-12.10^-9)kg-6” ► “(10^-2)yg-6” (YottaGram)
    指数因子可以使用.或*分隔。

  • 组合单位被重新排序,*也可以用作'.'分隔符。

    “(10^-3)kg-2*mm.(10^6)mol2” ►“Mmol2.mm.g-2”

然而,支持解析会带来一个之前不明显的问题:单位名称必须遵循某些规则以避免歧义。

使用当前代码库,你可以定义诸如“m2”之类的愚蠢单位……显然,新的单位名称(即基本单位或别名)不能包含数字。第一个规则将比这个更严格:单位的缩写必须只包含字母(Char.IsLetter)、符号(Char.IsSymbol)或我们的“#”用于“unit”。

由于我们支持所有标准的SI前缀(公制和二进制),并透明地应用它们,一旦定义了一个单位(例如“Sv”),其所有带前缀的版本应该*事实上*(或虚拟地)被定义。

ySv(yocto, 10⁻²⁴)、zSv(zepto, 10⁻²¹)、aSv(atto, 10⁻¹⁸)、fSv(femto, 10⁻¹⁵)、pSv(pico, 10⁻¹²)、nSv(nano, 10⁻⁹)、µSv(micro, 10⁻⁶)、mSv(milli, 10⁻³)、cSv(centi, 10⁻²)、dSv(deci, 10⁻¹)、daSv(deca, 10¹)、hSv(hecto, 10²)、kSv(kilo, 10³)、MSv(mega, 10⁶)、GSv(giga, 10⁹)、TSv(tera, 10¹²)、PSv(peta, 10¹⁵)、ESv(exa, 10¹⁸)、ZSv(zetta, 10²¹)、YSv(yotta, 10²⁴)、KiSv(kibi, 2¹⁰)、MiSv(mebi, 2²⁰)、GiSv(gibi, 2³⁰)、TiSv(tebi, 2⁴⁰)、PiSv(pebi, 2⁵⁰)、EiSv(exbi, 2⁶⁰)、ZiSv(zebi, 2⁷⁰)、YiSv(yobi, 2⁸⁰)。

注意:我们不对*传统上*与公制前缀一起使用的单位(如米)和使用(或*也*使用)二进制前缀的单位(如“B”/”Byte”)进行区分。

第二条规则是,在上下文中定义一个新单位之前(通过DefineAliasDefineFundamental):

  • 其自身带前缀的版本不得与现有缩写冲突。
    foreach( var withPrefix in MeasureStandardPrefix.All.Select( p => p.Abbreviation + a ) )
    {
        if( _allUnits.ContainsKey( withPrefix ) ) return false;
    }
  • 新缩写不得与任何现有缩写或潜在带前缀的缩写发生冲突。
    var prefix = MeasureStandardPrefix.FindPrefix( a );
    // Optimization: if the new abbreviation does not start with a
    // standard prefix, it is useless to challenge it against
    // existing units.
    if( prefix != MeasureStandardPrefix.None )
    {
        return !_allUnits.Values
                    .Where( u => u is FundamentalMeasureUnit || u is AliasMeasureUnit )
                    .SelectMany( u => MeasureStandardPrefix.All
                                        .Where( p => p != MeasureStandardPrefix.None )
                                        .Select( p => p.Abbreviation + u.Abbreviation ) )
                    .Any( exists => exists == a );
    }
    return true;

这可以工作,并且实际上可以防止冲突。

[Test]
public void when_minute_is_defined_inch_can_no_more_exist()
{
    var c = new StandardMeasureContext( "Empty" );
    var minute = c.DefineAlias( "min", "Minute", new FullFactor( 60 ), c.Second );
    c.IsValidNewAbbreviation( "in" ).Should().BeFalse();
}

你对此感到舒服吗?我不舒服。

到目前为止,我们一直认为任何标准前缀都可以应用于任何单位,但这就导致了这种行为:定义“in”/”Inch”会阻止定义“min”/”Minute”……

所有标准前缀都不应应用于所有单位。KiloHourMilliMinute通常是愚蠢的单位。如果需要,它们始终可以被显式定义为AliasMeasureUnit,但不应自动考虑。

第九天:世界冠军,选择性前缀与无量纲单位

今天,法国获得了第二个*;*但更重要的是,我们现在可以控制标准前缀是否适用于某个单位,并且无量纲单位是第一类单位的世界公民。

/// <summary>
/// Defines the automatic support of metric or binary standard prefixes
/// of a <see cref="AtomicMeasureUnit"/>.
/// </summary>
public enum AutoStandardPrefix
{
    /// <summary>
    /// The unit does not support automatic standard prefixes.
    /// </summary>
    None = 0,

    /// <summary>
    /// The unit supports automatic standard metric prefix (Kilo, Mega, etc.).
    /// </summary>
    Metric = 1,

    /// <summary>
    /// The unit supports automatic standard binary prefix (Kibi, Gibi, etc.).
    /// </summary>
    Binary = 2,

    /// <summary>
    /// The unit automatically support both binary and metric standard
    /// prefix (Kibi, Gibi, as well as Kilo, Mega, etc.).
    /// </summary>
    Both = 3
}

DefineAliasDefineFundamnetal现在接受上述enum(默认为None)。有了这个,分钟和英寸现在可以和平共处了。

[Test]
public void minute_and_inch_can_coexist_unless_inch_supports_metric_prefixes()
{
    var cM = new StandardMeasureContext( "WithMinute" );
    var minute = cM.DefineAlias( "min", "Minute", new FullFactor( 60 ), cM.Second );
    cM.IsValidNewAbbreviation( "in", AutoStandardPrefix.None ).Should().BeTrue();
    cM.IsValidNewAbbreviation( "in", AutoStandardPrefix.Binary ).Should().BeTrue();
    cM.IsValidNewAbbreviation( "in", AutoStandardPrefix.Metric ).Should().BeFalse();


    var cI = new StandardMeasureContext( "WithInchMetric" );
    var inch = cI.DefineAlias( "in",
                               "Inch",
                               2.54,
                               MeasureStandardPrefix.Centi[cI.Metre],
                               AutoStandardPrefix.Metric );

   cI.IsValidNewAbbreviation( "min", AutoStandardPrefix.None ).Should().BeFalse();
}

MeasureStandardPrefix应用于不支持该前缀的单位时,调整因子会处理指数。

[Test]
public void prefix_applied_to_non_prefixable_units_use_the_adjusment_factor()
{
    var c = new MeasureContext( "NoPrefix" );
    var tUnit = c.DefineFundamental( "T", "Thing", AutoStandardPrefix.Binary );
    var kiloT = MeasureStandardPrefix.Kilo[tUnit];
    var kibiT = MeasureStandardPrefix.Kibi[tUnit];
    kiloT.Abbreviation.Should().Be( "(10^3)T" );
    kibiT.Abbreviation.Should().Be( "KiT" );
}

我们现在在内部使用PrefixedMeasureUnit将无量纲单位定义为MeasureUnit.None单例的别名:10^5.2^6现在是一个有效的(无量纲)单位,并且解析已更新以处理此类无量纲单位。

[TestCase( "10^-1", "10^-1" )]
[TestCase( "2^7.10^-1*10^3.2^3", "10^2.2^10" )]
[TestCase( "10^-1*2^7.10^3.2^3", "10^2.2^10" )]
[TestCase( "%", "%" )]
[TestCase( "10^2.%", "" )]
[TestCase( "%.10^2", "" )]
[TestCase( "%.‱", "10^-6" )]
public void parsing_dimensionless_units( string text, string rewrite )
{
    var ctx = new StandardMeasureContext( "Empty" );
    var percent = ctx.DefineAlias( "%", "Percent", new ExpFactor( 0, -2 ), MeasureUnit.None );

    var permille = ctx.DefineAlias( "‰", "Permille", new ExpFactor( 0, -3 ), MeasureUnit.None );

    var pertenthousand = ctx.DefineAlias( "‱", "Pertenthousand", 
                                           new ExpFactor( 0, -4 ), MeasureUnit.None );

    ctx.TryParse( text, out var u ).Should().BeTrue();
    u.ToString().Should().Be( rewrite );
}

就这样:百分比和其他线性(指数基10或2)因子现在可以像其他单位一样使用。

[Test]
public void dimensionless_quantity_like_percent_or_permille_works()
{
    var percent = MeasureUnit.DefineAlias
                  ( "%", "Percent", new ExpFactor( 0, -2 ), MeasureUnit.None );

    var permille = MeasureUnit.DefineAlias
                   ( "‰", "Permille", new ExpFactor( 0, -3 ), MeasureUnit.None );

    var pertenthousand = MeasureUnit.DefineAlias
          ( "‱", "Pertenthousand", new ExpFactor( 0, -4 ), MeasureUnit.None );

    var pc10 = 10.WithUnit( percent );
    pc10.ToString().Should().Be( "10 %" );


    var pm20 = 20.WithUnit( permille );
    pm20.ToString().Should().Be( "20 ‰" );

    var pt30 = 30.WithUnit( pertenthousand );
    pt30.ToString().Should().Be( "30 ‱" );

    (pc10 * pm20 * pt30).ToString().Should().Be("6000 10^-9");
    (pc10 + pm20 + pt30).ToString( CultureInfo.InvariantCulture ).Should().Be( "12.3 %" );

    (pt30  + pc10 + pm20).ToString( CultureInfo.InvariantCulture ).Should().Be( "1230 ‱" );


    var km = MeasureStandardPrefix.Kilo[MeasureUnit.Metre];
    var km100 = 100.WithUnit( km );
    var pc10OfKm100 = pc10 * km100;
    pc10OfKm100.ToString().Should().Be( "1000 10^-2.km" );
    pc10OfKm100.ConvertTo( km ).ToString().Should().Be( "10 km" );
}

第九天结束。整个库的功能表面和一致性证明了CK.UnitsOfMeasure的0.1.0版本可以发布。

仍有改进的空间:

  • DecimalQuantityRationalQuantity可能有用。
  • 缩写名称检查(与前缀单位的检查)可能存在竞态条件。
  • 缩写中的允许字符目前硬编码在IsValidNewAbbreviation中。
    if( String.IsNullOrEmpty( a )
        || !a.All( c => Char.IsLetter( c )
        || Char.IsSymbol( c )
        || c == '#'
        || c == '%' || c == '‰' || c == '‱'
        || c == '㏙' ) )
    {
        return false;
    }

源代码和包

源代码在GNU Lesser General Public License v3.0下,可在此处获取:

NuGet包可在nuget.org上获取:

它没有任何依赖项,并且目标是netstandard2.0。

关注点

安全性

“安全性”是一个复杂概念。在这个库中,我试图考虑所有可能发生的边缘情况,并做出了我认为最安全的选择。例如:

  • 单位系统不会有越界风险。如果中间计算应用了疯狂的前缀或组合了数量,导致单位变得极其微小或巨大,它将由单位因子透明地处理。
  • MeasureContexts将单位组相互隔离。任何试图混合来自不同上下文的单位的尝试都会被检测到,但是,一些操作(如比较2个单位或数量)是可能的:这在需要处理单位或数量而不考虑其上下文时至关重要,通常是在基础设施代码(例如持久化层)中。
  • 命名唯一性和前缀处理是另一个不那么简单的事情(回想一下“milli inch”被缩写为“min”)。别名/前缀名称的管理方式尽可能保证单位系统将按照开发者的决定正确地执行操作,而不会出现意外。

浮点数地狱

第6天很有趣。如果你跳过了,请阅读。它也与“安全性”有关,在这个领域,浮点数不是好公民。关于相等性(及其伴随的GetHashCode)意味着什么的讨论,应该会引起任何开发者的兴趣。我很想听听对此问题的任何替代解决方案。

单例……或不是

这个库展示了一种提供实际上不是单例的单例的简单方法。好处是显而易见的:你拥有单例的简单性,而没有其任何缺点,只有一个:一旦你使用了默认实例,你的代码库就依赖于一个隐式依赖,并且如果你的程序规模变大,你将不得不承担代价。

就库而言,这是完美的,因为它不强制你任何架构。小型项目可以使用单例,大型项目应该实例化、管理、注入“非单例”服务,就像其他服务一样。

我曾多次使用这种模式,但没有在别处看到它被清晰地暴露出来。

结论:我们工作的乐趣

这个项目是我日常工作的绝佳范例。考虑一个问题,一个疑问(“我们应该如何管理数量?”),寻找现有的答案,阅读相关内容(这就像回到学校,我的物理课很久以前了),用你能想象到的最简单的方式来建模,但也要为未来的扩展做好准备,挑战它,重构,重新建模,再挑战……然后享受。

成功是旅程,不是终点。过程往往比结果更重要。- 亚瑟·阿什

更新

2019-05-21

2020-01-21

  • NetCore 3 改变了Double.ToString()的输出……这是最好的:ToString()方法现在默认是“roundtrippable”。(请看这篇优秀的博文:https://devblogs.microsoft.com/dotnet/floating-point-parsing-and-formatting-improvements-in-net-core-3-0/。)
    然而,这对本库造成了一个严重问题,因为我们使用字符串表示作为有效的“舍入相等实现”。
    为了适应这种变化,一个新的Quantity.ToRoundedString()方法显式地使用“G15”格式调用Double.ToString(string, IFormatProvider)(使用CultureInfo.InvariantCulture),以获得一个消除微小舍入调整的字符串。这完美地满足了我们的“相等性”需求。
  • 感谢Dave Hary(见下文的问答),我们现在可以将无量纲单位互相转换了。
    -之前:即使kg.g⁻¹被规范化为无量纲的Measure.None,但无法将其转换为Measure.None或其他导出无量纲单位(如%或千分尺)。
    -现在:通过移除一行代码(见下文的答案),我们可以转换这种无量纲数量为纯比率(Measure.None)或%或任何其他此类单位。

2020-02-11

  • 关于Dave Hary在其第一个问题中提出的隐式转换/简化,这将强制隐式选择一个简化的单位,但它会影响乘法因子。

    例如:将“5 kg”乘以“7 g”得到“35 g.kg”(单位按降幂然后按字典序排列)。这两个单位共享相同的规范化单位(kg):一个可以将其简化为kg²……然而,这对值本身有明显影响,正如以下测试所示。
[Test]
public void automatic_unit_simplification_impacts_the_value()
{
  var q = 5.WithUnit( MeasureUnit.Kilogram ) * 7.WithUnit( MeasureUnit.Gram );
  q.ToString().Should().Be( "35 g.kg" );

  q.Unit.Normalization.Should().Be( MeasureUnit.Kilogram * MeasureUnit.Kilogram );
  q.Unit.NormalizationFactor.Should().Be( new ExpFactor( 0, -3 ) );

  var qkg = q.ConvertTo( MeasureUnit.Kilogram * MeasureUnit.Kilogram );
  qkg.Value.Should().Be( 35.0 / 1000 );

  var qg = q.ConvertTo( MeasureUnit.Gram * MeasureUnit.Gram );
  qg.Value.Should().Be( 35.0 * 1000 );
}

问题:值应该是0.035(在kg²中)还是35000(在g²中)?

答案:我们认为我们不应该决定这一点:由用户/开发人员决定使用“规范化”单位(*总是可用*)或通过转换来使用它的任何变体。
 

今天发布了一个新版本,包含这些更改:https://nuget.net.cn/packages/CK.UnitsOfMeasure/0.2.0

© . All rights reserved.