9天内制作一个动态度量单位库
这个小型库以一种动态和多上下文的方法处理单位和数量。
引言
这是我在Code Project上的第一篇文章。我经常接触一些太大、太具体或太无用的项目,但这个项目足够小巧有趣,值得在这里分享。我花了9天时间来完成它,并记录了每一天的活动。我希望这篇文章能被看作是一篇“软件开发之旅记”。每一天都实现了功能并编写了单元测试,有时也进行重构(有时甚至回滚之前的代码):这就是典型的开发者生活。
形式上就说这么多。内容如何呢?
我需要处理单位和数量,主要是国际单位制(SI)的单位(但也可能包含更晦涩的单位),并且需要在一个数据库中持久化数量及其单位的系统中进行处理,同时允许多个“单位上下文”共存(可以想象成多租户)。
背景
我找到的C#解决方案都很有意思,但都不完全满足我的需求。这里简单谈谈其中两个:
- https://codeproject.org.cn/Articles/413750/Units-of-Measure-Validator-for-Csharp 使用属性,可以处理(在可能的情况下)非线性转换,并旨在检查转换的有效性。方法很有趣,但不是我的目标。
- https://github.com/angularsen/UnitsNet 是一个强大、庞大的库,它使用T4生成强类型单位和扩展方法。一切都是“硬编码”的,只要单位集*明确*覆盖了*所有*你的需求,它就能完美工作。
如果你再深入一点,会发现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_system 和 https://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
设置为1
。BasicMeasureUnit
绑定到一个带指数的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
类,它是FundamentalMeasureUnit
和PrefixedMeasureUnit
的基类。
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
定义为基本单位而不是Kilogram
。Kilogram
现在在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
定义了从double
和ExpFactor
的隐式转换运算符。我们可以以更简单的方式定义牛顿:
var newton = DefineAlias( "N", "Newton", 1.0, kg * m * (s ^ -2) );
到目前为止一切顺利……但是……“N.dyn-1”
的实际单位是什么?
你可能会惊讶,但它可能是“弧度”(或“球面度”),因为这实际上没有单位:它就是Measure.None
(就像弧度和球面度不是实际单位一样)。
现在我们可以使用这些单位来*计算*数量了!
第四天:单位规范化,开始计算
Quantity
(数量)就是一个数值(int
、float
、double
、有理数、大整数等)与其单位的关联。
本库的目标之一是帮助计算这样的数量,例如:
上面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
(它变得更大),并且为了保持代码整洁,它被分割成部分文件。
最终的类图是:
图:最终模型
我们可以合并Exponent
和Atomic
,但我们没有这样做,也不想这样做。原因有二:
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}";
}
通过int
和double
上的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/Ounce 或https://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
,它公开并已定义Unit
、Bit
、Byte
和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.Normalization
和NormalizationFactor
作为“目标”数量。
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.Round
(https://docs.microsoft.com/en-us/dotnet/api/system.math.round)转换后的结果。- ► 什么样的四舍五入?精度如何?我不知道。
GetHashCode()
是一个必须快速运行的方法。仅转换就已耗时,添加后续的四舍五入可能不是个好主意。
- ► 什么样的四舍五入?精度如何?我不知道。
- 如当前实现所示。
- ► 寻找另一种
Equals
和GetHashCode
- 对象相等性必须是“一致的”,遵循“最小惊讶原则”,不一定“精确”(双精度数“精确”是什么意思?)。
想法是简单地使用规范化数量的字符串表示。两个数量相等当且仅当它们的规范化表示相同时。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
- newQuantity()
- 有一个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
,所有其他上下文都必须使用名称构造。多亏了这一点,绑定到不同上下文单位的数量就可以进行比较了。
混合来自不同上下文的单位实际上不应该发生。上下文的概念是本库的一个可选功能,在某些场景下(通常涉及多租户和/或持久化数据)是相关的。本库不包含上下文名称的注册表或其他中央字典:上下文在使用时,用于隔离单位,因此应单独使用。
Quantity
的CompareTo
和比较运算符可用。顺便说一句,一个可怕的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”
)进行区分。
第二条规则是,在上下文中定义一个新单位之前(通过DefineAlias
或DefineFundamental
):
- 其自身带前缀的版本不得与现有缩写冲突。
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”
……
所有标准前缀都不应应用于所有单位。KiloHour
或MilliMinute
通常是愚蠢的单位。如果需要,它们始终可以被显式定义为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
}
DefineAlias
和DefineFundamnetal
现在接受上述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版本可以发布。
仍有改进的空间:
DecimalQuantity
或RationalQuantity
可能有用。- 缩写名称检查(与前缀单位的检查)可能存在竞态条件。
- 缩写中的允许字符目前硬编码在
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
- 添加了对“每个计算机科学家都应该知道的关于浮点算术的内容”的引用:https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html
- 修复了代码语言和次要样式问题
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。