如何在C#中创建一个通用的计量单位转换器?

8
我一直在学习委托和Lambda表达式,同时还在一个小烹饪项目中涉及温度转换以及一些烹饪度量单位的转换,例如Imperial到Metric,我一直在思考如何制作一个可扩展的单位转换器。
以下是我开始使用的内容,以及有关我的计划的代码注释。我没有计划像下面这样使用它,我只是在测试我不太了解的C#功能,我也不确定如何进一步使用它。是否有人对如何创建我在下面评论中所说内容有任何建议?谢谢
namespace TemperatureConverter
{
    class Program
    {
        static void Main(string[] args)
        {
            // Fahrenheit to Celsius :  [°C] = ([°F] − 32) × 5⁄9
            var CelsiusResult = Converter.Convert(11M,Converter.FahrenheitToCelsius);

            // Celsius to Fahrenheit : [°F] = [°C] × 9⁄5 + 32
            var FahrenheitResult = Converter.Convert(11M, Converter.CelsiusToFahrenheit);

            Console.WriteLine("Fahrenheit to Celsius : " + CelsiusResult);
            Console.WriteLine("Celsius to Fahrenheit : " + FahrenheitResult);
            Console.ReadLine();

            // If I wanted to add another unit of temperature i.e. Kelvin 
            // then I would need calculations for Kelvin to Celsius, Celsius to Kelvin, Kelvin to Fahrenheit, Fahrenheit to Kelvin
            // Celsius to Kelvin : [K] = [°C] + 273.15
            // Kelvin to Celsius : [°C] = [K] − 273.15
            // Fahrenheit to Kelvin : [K] = ([°F] + 459.67) × 5⁄9
            // Kelvin to Fahrenheit : [°F] = [K] × 9⁄5 − 459.67
            // The plan is to have the converters with a single purpose to convert to
            //one particular unit type e.g. Celsius and create separate unit converters 
            //that contain a list of calculations that take one specified unit type and then convert to their particular unit type, in this example its Celsius.
        }
    }

    // at the moment this is a static class but I am looking to turn this into an interface or abstract class
    // so that whatever implements this interface would be supplied with a list of generic deligate conversions
    // that it can invoke and you can extend by adding more when required.
    public static class Converter
    {
        public static Func<decimal, decimal> CelsiusToFahrenheit = x => (x * (9M / 5M)) + 32M;
        public static Func<decimal, decimal> FahrenheitToCelsius = x => (x - 32M) * (5M / 9M);

        public static decimal Convert(decimal valueToConvert, Func<decimal, decimal> conversion) {
            return conversion.Invoke(valueToConvert);
        }
    }
}

更新:尝试澄清我的问题:

以我下面的温度示例为例,如何创建一个类,其中包含一系列将给定温度转换为摄氏度的lambda转换,您可以将该温度传递给它,并尝试将其转换为摄氏度(如果可用计算)

伪代码示例:

enum Temperature
{
    Celcius,
    Fahrenheit,
    Kelvin
}

UnitConverter CelsiusConverter = new UnitConverter(Temperature.Celsius);
CelsiusConverter.AddCalc("FahrenheitToCelsius", lambda here);
CelsiusConverter.Convert(Temperature.Fahrenheit, 11);

F#支持度量单位,我认为这将是C# vNext的一个很好的功能。目前我找到了这个项目QuantityTypes,它在C#中实现了度量单位。 - orad
7个回答

27

我认为这是一个很有趣的小问题,所以我决定看看它如何能够被封装成一个通用实现。这个实现没有经过充分的测试(也不处理所有的错误情况 - 如如果您没有注册某个单位类型的转换,然后将其传递给它),但它可能会有用。重点在于使继承类 (TemperatureConverter) 尽可能整洁。

/// <summary>
/// Generic conversion class for converting between values of different units.
/// </summary>
/// <typeparam name="TUnitType">The type representing the unit type (eg. enum)</typeparam>
/// <typeparam name="TValueType">The type of value for this unit (float, decimal, int, etc.)</typeparam>
abstract class UnitConverter<TUnitType, TValueType>
{
    /// <summary>
    /// The base unit, which all calculations will be expressed in terms of.
    /// </summary>
    protected static TUnitType BaseUnit;

    /// <summary>
    /// Dictionary of functions to convert from the base unit type into a specific type.
    /// </summary>
    static ConcurrentDictionary<TUnitType, Func<TValueType, TValueType>> ConversionsTo = new ConcurrentDictionary<TUnitType, Func<TValueType, TValueType>>();

    /// <summary>
    /// Dictionary of functions to convert from the specified type into the base unit type.
    /// </summary>
    static ConcurrentDictionary<TUnitType, Func<TValueType, TValueType>> ConversionsFrom = new ConcurrentDictionary<TUnitType, Func<TValueType, TValueType>>();

    /// <summary>
    /// Converts a value from one unit type to another.
    /// </summary>
    /// <param name="value">The value to convert.</param>
    /// <param name="from">The unit type the provided value is in.</param>
    /// <param name="to">The unit type to convert the value to.</param>
    /// <returns>The converted value.</returns>
    public TValueType Convert(TValueType value, TUnitType from, TUnitType to)
    {
        // If both From/To are the same, don't do any work.
        if (from.Equals(to))
            return value;

        // Convert into the base unit, if required.
        var valueInBaseUnit = from.Equals(BaseUnit)
                                ? value
                                : ConversionsFrom[from](value);

        // Convert from the base unit into the requested unit, if required
        var valueInRequiredUnit = to.Equals(BaseUnit)
                                ? valueInBaseUnit
                                : ConversionsTo[to](valueInBaseUnit);

        return valueInRequiredUnit;
    }

    /// <summary>
    /// Registers functions for converting to/from a unit.
    /// </summary>
    /// <param name="convertToUnit">The type of unit to convert to/from, from the base unit.</param>
    /// <param name="conversionTo">A function to convert from the base unit.</param>
    /// <param name="conversionFrom">A function to convert to the base unit.</param>
    protected static void RegisterConversion(TUnitType convertToUnit, Func<TValueType, TValueType> conversionTo, Func<TValueType, TValueType> conversionFrom)
    {
        if (!ConversionsTo.TryAdd(convertToUnit, conversionTo))
            throw new ArgumentException("Already exists", "convertToUnit");
        if (!ConversionsFrom.TryAdd(convertToUnit, conversionFrom))
            throw new ArgumentException("Already exists", "convertToUnit");
    }
}

这里的泛型参数是用来表示单位和数值类型的枚举。要使用它,只需继承此类(提供类型),并注册一些lambda函数来进行转换。以下是一个温度示例(带有一些虚假的计算):

enum Temperature
{
    Celcius,
    Fahrenheit,
    Kelvin
}

class TemperatureConverter : UnitConverter<Temperature, float>
{
    static TemperatureConverter()
    {
        BaseUnit = Temperature.Celcius;
        RegisterConversion(Temperature.Fahrenheit, v => v * 2f, v => v * 0.5f);
        RegisterConversion(Temperature.Kelvin, v => v * 10f, v => v * 0.05f);
    }
}

然后使用它非常简单:

var converter = new TemperatureConverter();

Console.WriteLine(converter.Convert(1, Temperature.Celcius, Temperature.Fahrenheit));
Console.WriteLine(converter.Convert(1, Temperature.Fahrenheit, Temperature.Celcius));

Console.WriteLine(converter.Convert(1, Temperature.Celcius, Temperature.Kelvin));
Console.WriteLine(converter.Convert(1, Temperature.Kelvin, Temperature.Celcius));

Console.WriteLine(converter.Convert(1, Temperature.Kelvin, Temperature.Fahrenheit));
Console.WriteLine(converter.Convert(1, Temperature.Fahrenheit, Temperature.Kelvin));

正是我想要的,非常好的答案 :) 有很多需要阅读和理解的内容,但非常感谢您的帮助。 - Pricey
1
需要指出的一件事是,使用这样的基本单位可能会导致一些意想不到的结果。例如,如果您使用这个来将12英寸转换为以米为基本单位的英尺,您可能会得到1.000031英尺或其他奇怪的结果。如果您的应用程序执行一些非常复杂的转换并在其他计算中使用它们,结果可能会略有偏差。 - Scribblemacher

5
你做得很好,但正如Jon所说,当前它并不是类型安全的;转换器没有错误检查以确保它所获得的十进制数是摄氏度值。
因此,为了更进一步,我会开始引入结构类型,将数字值应用于度量单位。在企业架构模式(也称为四人帮设计模式)中,这被称为“货币”模式,因为它最常见的用途是表示某种货币金额。该模式适用于任何需要单位才能具有意义的数值量。
例如:
public enum TemperatureScale
{
   Celsius,
   Fahrenheit,
   Kelvin
}

public struct Temperature
{
   decimal Degrees {get; private set;}
   TemperatureScale Scale {get; private set;}

   public Temperature(decimal degrees, TemperatureScale scale)
   {
       Degrees = degrees;
       Scale = scale;
   }

   public Temperature(Temperature toCopy)
   {
       Degrees = toCopy.Degrees;
       Scale = toCopy.Scale;
   }
}

现在,你有一个简单的类型可以用来强制执行你所做的转换采用正确比例的温度,并返回已知在另一种比例的结果温度。
你的函数需要额外的一行来检查输入是否与输出匹配; 你可以继续使用lambda表达式,或者可以进一步采用简单的策略模式:
public interface ITemperatureConverter
{
   public Temperature Convert(Temperature input);
}

public class FahrenheitToCelsius:ITemperatureConverter
{
   public Temperature Convert(Temperature input)
   {
      if (input.Scale != TemperatureScale.Fahrenheit)
         throw new ArgumentException("Input scale is not Fahrenheit");

      return new Temperature(input.Degrees * 5m / 9m - 32, TemperatureScale.Celsius);
   }
}

//Implement other conversion methods as ITemperatureConverters

public class TemperatureConverter
{
   public Dictionary<Tuple<TemperatureScale, TemperatureScale>, ITemperatureConverter> converters = 
      new Dictionary<Tuple<TemperatureScale, TemperatureScale>, ITemperatureConverter>
      {
         {Tuple.Create<TemperatureScale.Fahrenheit, TemperatureScale.Celcius>,
            new FahrenheitToCelsius()},
         {Tuple.Create<TemperatureScale.Celsius, TemperatureScale.Fahrenheit>,
            new CelsiusToFahrenheit()},
         ...
      }

   public Temperature Convert(Temperature input, TemperatureScale toScale)
   {
      if(!converters.ContainsKey(Tuple.Create(input.Scale, toScale))
         throw new InvalidOperationException("No converter available for this conversion");

      return converters[Tuple.Create(input.Scale, toScale)].Convert(input);
   }
}

由于这些类型的转换是双向的,因此您可以考虑设置接口来处理双向转换,使用"ConvertBack"方法或类似方法将摄氏度温度转换为华氏度。这样可以减少类的数量。然后,您的字典值可以是指向转换器实例上方法的指针,而不是类的实例。这增加了设置主TemperatureConverter策略选择器的复杂性,但减少了必须定义的转换策略类的数量。
还要注意,在实际进行转换时运行时进行错误检查,需要在所有用法中彻底测试此代码以确保始终正确。为避免这种情况,可以派生基本Temperature类以生成CelsiusTemperature和FahrenheitTemperature结构体,它们仅简单地将其比例定义为常量值。然后,ITemperatureConverter可以使两种类型都通用,即两个Temperature,从而使您在指定您认为的转换时进行编译时检查。TemperatureConverter也可以动态查找ITemperatureConverters,确定它们将转换的类型,并自动设置转换器字典,因此您永远不必担心添加新的转换器。这将增加基于Temperature的类计数的成本;您需要四个域类(一个基类和三个派生类)而不是一个。创建TemperatureConverter类的速度也会变慢,因为反射构建转换器字典的代码将使用相当多的反射。
您还可以更改用于度量单位的枚举,使其成为"标记类";空类,除了它们是该类并派生自其他类之外,没有其他含义。然后,您可以定义完整的"UnitOfMeasure"类层次结构,表示各种度量单位,并可用作通用类型参数和约束;ITemperatureConverter可以使两种类型都通用,这两种类型都受到TemperatureScale类的限制,而CelsiusFahrenheitConverter实现将关闭通用接口以由TemperatureScale派生出的CelsiusDegrees和FahrenheitDegrees类型。这样就可以将测量单位本身公开为转换的约束条件,从而允许不同单位之间的转换(某些材料的某些单位具有已知的转换;1英国皇家品脱水重1.25磅)。
所有这些都是设计决策,将简化此设计的一种类型的更改,但会付出一定的代价(要么使其他事情更难做,要么降低算法性能)。在您工作的整个应用程序和编码环境的上下文中,您可以决定什么对您来说真正"容易"。
编辑:根据您的编辑,您想要的使用非常适用于温度。但是,如果您想要一个通用的UnitConverter,可以使用任何UnitofMeasure,则不再需要枚举来表示您的度量单位,因为枚举无法具有自定义继承层次结构(它们直接派生自System.Enum)。
您可以指定默认构造函数可以接受任何枚举,但是然后必须确保枚举是作为度量单位之一的类型之一,否则可能会传递一个DialogResult值,转换器将在运行时出现问题。

相反,如果您想要一个可以根据其他度量单位的lambda表达式转换任何度量单位的UnitConverter,我会将度量单位指定为“标记类”;这些小型无状态“令牌”仅具有意义,因为它们是自己的类型并从其父类继承:

//The only functionality any UnitOfMeasure needs is to be semantically equatable
//with any other reference to the same type.
public abstract class UnitOfMeasure:IEquatable<UnitOfMeasure> 
{ 
   public override bool Equals(UnitOfMeasure other)
   {
      return this.ReferenceEquals(other)
         || this.GetType().Name == other.GetType().Name;
   }

   public override bool Equals(Object other) 
   {
      return other is UnitOfMeasure && this.Equals(other as UnitOfMeasure);
   }    

   public override operator ==(Object other) {return this.Equals(other);}
   public override operator !=(Object other) {return this.Equals(other) == false;}

}

public abstract class Temperature:UnitOfMeasure {
public static CelsiusTemperature Celsius {get{return new CelsiusTemperature();}}
public static FahrenheitTemperature Fahrenheit {get{return new CelsiusTemperature();}}
public static KelvinTemperature Kelvin {get{return new CelsiusTemperature();}}
}
public class CelsiusTemperature:Temperature{}
public class FahrenheitTemperature :Temperature{}
public class KelvinTemperature :Temperature{}

...

public class UnitConverter
{
   public UnitOfMeasure BaseUnit {get; private set;}
   public UnitConverter(UnitOfMeasure baseUnit) {BaseUnit = baseUnit;}

   private readonly Dictionary<UnitOfMeasure, Func<decimal, decimal>> converters
      = new Dictionary<UnitOfMeasure, Func<decimal, decimal>>();

   public void AddConverter(UnitOfMeasure measure, Func<decimal, decimal> conversion)
   { converters.Add(measure, conversion); }

   public void Convert(UnitOfMeasure measure, decimal input)
   { return converters[measure](input); }
}

您可以加入错误检查(如检查输入单元是否指定了转换,检查添加的转换是否针对具有相同父级的基本类型的UOM等),以使其更加稳定。您也可以派生UnitConverter来创建TemperatureConverter,从而允许您添加静态编译时类型检查,并避免UnitConverter必须使用的运行时检查。


对于您出色的回答,我给予大加赞赏。您的评论真的帮助了我考虑到了一些我之前没考虑到的问题。 - Pricey

4
听起来你需要的是这样的东西:
Func<decimal, decimal> celsiusToKelvin = x => x + 273.15m;
Func<decimal, decimal> kelvinToCelsius = x => x - 273.15m;
Func<decimal, decimal> fahrenheitToKelvin = x => ((x + 459.67m) * 5m) / 9m;
Func<decimal, decimal> kelvinToFahrenheit = x => ((x * 9m) / 5m) - 459.67m;

然而,你可能不仅仅想使用decimal,而是要有一种类型能够知道单位,这样你就不会无意中(比如)将“摄氏度转开尔文”的计算应用于非摄氏度值。可以参考F#的度量单位方法作为启发。


是的,我正在尝试创建一个实现Unit Converter接口的类,该特定类只知道如何将温度类型列表转换为摄氏度,但我可以创建一个全新的类来实现单位转换器接口,并知道如何将杯量转换为汤匙或英制转换为公制等。我会尝试编辑我的问题以进一步解释自己。再次感谢F# Units of Measure链接 :) - Pricey
抱歉,在我之前的评论中,从英制单位到公制单位的转换不是一个好的例子,更好的例子是从磅到克,这更加具体。 - Pricey

2
你可以看一下Units.NET。它在 GitHubNuGet 上。它提供了大多数常见的单位和转换,支持静态类型和枚举单位以及缩写的解析/打印。但它不解析表达式,也不能扩展现有的单位类,但你可以用新的第三方单位进行扩展。
示例转换:
Length meter = Length.FromMeters(1);
double cm = meter.Centimeters; // 100
double feet = meter.Feet; // 3.28084

0
通常我想把这个作为对Danny Tuppeny帖子的评论添加,但似乎我无法添加评论。
我稍微改进了@Danny Tuppeny的解决方案。我不想每次都添加两个转换因子,因为只需要一个就足够了。而且,类型为Func的参数似乎并不必要,它只会让用户感到更加复杂。
所以我的调用看起来像这样:
public enum TimeUnit
{
    Milliseconds,
    Second,
    Minute,
    Hour,
    Day,
    Week
}

public class TimeConverter : UnitConverter<TimeUnit, double>
{
    static TimeConverter()
    {
        BaseUnit = TimeUnit.Second;
        RegisterConversion(TimeUnit.Milliseconds, 1000);
        RegisterConversion(TimeUnit.Minute, 1/60);
        RegisterConversion(TimeUnit.Hour, 1/3600);
        RegisterConversion(TimeUnit.Day, 1/86400);
        RegisterConversion(TimeUnit.Week, 1/604800);
    }
}

我还添加了一个方法来获取两个单位之间的转换系数。 这是修改后的UnitConverter类:

/// <summary>
/// Generic conversion class for converting between values of different units.
/// </summary>
/// <typeparam name="TUnitType">The type representing the unit type (eg. enum)</typeparam>
/// <typeparam name="TValueType">The type of value for this unit (float, decimal, int, etc.)</typeparam>
/// <remarks>https://dev59.com/bVzUa4cB1Zd3GeqP3XV-
/// </remarks>
public abstract class UnitConverter<TUnitType, TValueType> where TValueType : struct, IComparable, IComparable<TValueType>, IEquatable<TValueType>, IConvertible
{
    /// <summary>
    /// The base unit, which all calculations will be expressed in terms of.
    /// </summary>
    protected static TUnitType BaseUnit;

    /// <summary>
    /// Dictionary of functions to convert from the base unit type into a specific type.
    /// </summary>
    static ConcurrentDictionary<TUnitType, Func<TValueType, TValueType>> ConversionsTo = new ConcurrentDictionary<TUnitType, Func<TValueType, TValueType>>();

    /// <summary>
    /// Dictionary of functions to convert from the specified type into the base unit type.
    /// </summary>
    static ConcurrentDictionary<TUnitType, Func<TValueType, TValueType>> ConversionsFrom = new ConcurrentDictionary<TUnitType, Func<TValueType, TValueType>>();

    /// <summary>
    /// Converts a value from one unit type to another.
    /// </summary>
    /// <param name="value">The value to convert.</param>
    /// <param name="from">The unit type the provided value is in.</param>
    /// <param name="to">The unit type to convert the value to.</param>
    /// <returns>The converted value.</returns>
    public TValueType Convert(TValueType value, TUnitType from, TUnitType to)
    {
        // If both From/To are the same, don't do any work.
        if (from.Equals(to))
            return value;

        // Convert into the base unit, if required.
        var valueInBaseUnit = from.Equals(BaseUnit)
                                ? value
                                : ConversionsFrom[from](value);

        // Convert from the base unit into the requested unit, if required
        var valueInRequiredUnit = to.Equals(BaseUnit)
                                ? valueInBaseUnit
                                : ConversionsTo[to](valueInBaseUnit);

        return valueInRequiredUnit;
    }

    public double ConversionFactor(TUnitType from, TUnitType to)
    {
        return Convert(One(), from, to).ToDouble(CultureInfo.InvariantCulture);
    }

    /// <summary>
    /// Registers functions for converting to/from a unit.
    /// </summary>
    /// <param name="convertToUnit">The type of unit to convert to/from, from the base unit.</param>
    /// <param name="conversionToFactor">a factor converting into the base unit.</param>
    protected static void RegisterConversion(TUnitType convertToUnit, TValueType conversionToFactor)
    {
        if (!ConversionsTo.TryAdd(convertToUnit, v=> Multiply(v, conversionToFactor)))
            throw new ArgumentException("Already exists", "convertToUnit");

        if (!ConversionsFrom.TryAdd(convertToUnit, v => MultiplicativeInverse(conversionToFactor)))
            throw new ArgumentException("Already exists", "convertToUnit");
    }

    static TValueType Multiply(TValueType a, TValueType b)
    {
        // declare the parameters
        ParameterExpression paramA = Expression.Parameter(typeof(TValueType), "a");
        ParameterExpression paramB = Expression.Parameter(typeof(TValueType), "b");
        // add the parameters together
        BinaryExpression body = Expression.Multiply(paramA, paramB);
        // compile it
        Func<TValueType, TValueType, TValueType> multiply = Expression.Lambda<Func<TValueType, TValueType, TValueType>>(body, paramA, paramB).Compile();
        // call it
        return multiply(a, b);
    }

    static TValueType MultiplicativeInverse(TValueType b)
    {
        // declare the parameters
        ParameterExpression paramA = Expression.Parameter(typeof(TValueType), "a");
        ParameterExpression paramB = Expression.Parameter(typeof(TValueType), "b");
        // add the parameters together
        BinaryExpression body = Expression.Divide(paramA, paramB);
        // compile it
        Func<TValueType, TValueType, TValueType> divide = Expression.Lambda<Func<TValueType, TValueType, TValueType>>(body, paramA, paramB).Compile();
        // call it
        return divide(One(), b);
    }

    //Returns the value "1" as converted Type
    static TValueType One()
    {
        return (TValueType) System.Convert.ChangeType(1, typeof (TValueType));
    }
}

这仅适用于您的转换逻辑涉及乘法(或除法)。对于像摄氏度到华氏度的转换,这变得无法使用。 - Ε Г И І И О

0
可以定义一个物理单位的通用类型,使得如果每个单位都有一个实现new并包括该单位与该类型的“基本单位”之间的转换方法的类型,则可以对以不同单位表示的值执行算术运算,并根据需要进行转换,使用类型系统,使得类型为AreaUnit<LengthUnit.Inches>的变量只接受以平方英寸为尺寸的事物,但如果说myAreaInSquareInches= AreaUnit<LengthUnit.Inches>.Product(someLengthInCentimeters, someLengthInFathoms);,它会在执行乘法之前自动转换这些其他单位。当使用方法调用语法时,它实际上可以很好地工作,因为像Product<T1,T2>(T1 p1, T2 p2)这样的方法可以接受其操作数的通用类型参数。不幸的是,没有办法使运算符成为通用的,也没有办法让像AreaUnit<T> where T:LengthUnitDescriptor这样的类型定义与其他任意通用类型AreaUnit<U>之间的转换方式。AreaUnit<T>可以定义例如与AreaUnit<Angstrom>之间的转换,但编译器无法告诉代码给定一个AreaUnit<Centimeters> and wantsAreaUnit`可以将英寸转换为埃和厘米。

0
上面有很好的答案,如果你想使用委托和lambda表达式也是可以的,但是为了更简单一些,你可以考虑使用一个中间测量单位。例如,如果你要将英寸转换为米,你可以先将英寸转换为毫米,然后再将毫米转换为米。使用中间单位意味着你只需要为每个单位编写两个转换函数:到毫米的转换和从毫米的转换。

Image of graph showing (ft) * ~304 -> (mm) * ~.00109 -> (yd)


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