Шаблон для создания простого и эффективного типа значения

Мотивация:

Читая блог Марка Симанна на тему Code Smell: Automatic Property, он говорит рядом с конец:

Суть в том, что автоматические свойства редко подходят. На самом деле они подходят только тогда, когда тип свойства является типом значения и разрешены все мыслимые значения.

Он приводит int Temperature в качестве примера неприятного запаха и предлагает лучшее решение - тип значения, зависящий от единицы измерения, например, по Цельсию. Поэтому я решил попробовать написать пользовательский тип значения Цельсия, который инкапсулирует всю логику проверки границ и преобразования типов, в качестве упражнения на то, чтобы быть более 29" rel="noreferrer">SOLID.

Основные требования:

  1. Недопустимое значение
  2. Инкапсулирует операции преобразования
  3. Эффективное преодоление (эквивалентно его замене)
  4. Максимально интуитивно понятный в использовании (пытаясь понять семантику int)

Реализация:

[System.Diagnostics.DebuggerDisplay("{m_value}")]
public struct Celsius // : IComparable, IFormattable, etc...
{
    private int m_value;

    public static readonly Celsius MinValue = new Celsius() { m_value = -273 };           // absolute zero
    public static readonly Celsius MaxValue = new Celsius() { m_value = int.MaxValue };

    private Celsius(int temp)
    {
        if (temp < Celsius.MinValue)
            throw new ArgumentOutOfRangeException("temp", "Value cannot be less then Celsius.MinValue (absolute zero)");
        if (temp > Celsius.MaxValue)
            throw new ArgumentOutOfRangeException("temp", "Value cannot be more then Celsius.MaxValue");

        m_value = temp;
    }

    public static implicit operator Celsius(int temp)
    {
        return new Celsius(temp);
    }

    public static implicit operator int(Celsius c)
    {
        return c.m_value;
    }

    // operators for other numeric types...

    public override string ToString()
    {
        return m_value.ToString();
    }

    // override Equals, HashCode, etc...
}

Тесты:

[TestClass]
public class TestCelsius
{
    [TestMethod]
    public void QuickTest()
    {
        Celsius c = 41;             
        Celsius c2 = c;
        int temp = c2;              
        Assert.AreEqual(41, temp);
        Assert.AreEqual("41", c.ToString());
    }

    [TestMethod]
    public void OutOfRangeTest()
    {
        try
        {
            Celsius c = -300;
            Assert.Fail("Should not be able to assign -300");
        }
        catch (ArgumentOutOfRangeException)
        {
            // pass
        }
        catch (Exception)
        {
            Assert.Fail("Threw wrong exception");
        }
    }
}

Вопросы:

  • Есть ли способ сделать MinValue/MaxValue константными, а не только для чтения? Глядя на BCL, мне нравится, как определение метаданных int четко указывает MaxValue и MinValue как константы времени компиляции. Как я могу имитировать это? Я не вижу способа создать объект Celsius без вызова конструктора или раскрытия деталей реализации, в которых Celsius хранит int.
  • Могу ли я пропустить какие-либо функции удобства использования?
  • Есть ли лучший шаблон для создания пользовательского типа значения одного поля?

person ErnieL    schedule 07.11.2011    source источник
comment
Проверьте этот вопрос (некоторый ответ на недостающую часть функций удобства использования) - stackoverflow.com/questions/441309/why-are-mutable-structs-evil и ссылки оттуда. Полезно для всех типов значений.   -  person Alexei Levenkov    schedule 07.11.2011
comment
+1 за вопрос о том, как стать более ТВЕРДЫМ.   -  person JonH    schedule 07.11.2011
comment
@Alexei — я уже читал все посты «изменяемые структуры — это зло». Я согласен. Проблема в том, что если я сделаю приватное поле доступным только для чтения, то Celcius.MaxValue вызовет конструктор, который требует, чтобы Celsius.MaxValue уже был определен. Это циклично и приводит к исключению во время выполнения. Вот почему я использую конструктор по умолчанию в определении MaxValue. Вы знаете способ обойти это? Приватный конструктор специального назначения «не проверять границы» кажется неправильным.   -  person ErnieL    schedule 08.11.2011
comment
Я этого не понимал. Я думаю, что наличие специального метода (private CreateConstantValue()?), который создает константы для данного типа, было бы полезно для самодокументирования кода - глядя на код в том виде, в котором он есть сейчас, невозможно узнать, почему вы должны вызывать конструктор по умолчанию.   -  person Alexei Levenkov    schedule 08.11.2011


Ответы (4)


Есть ли способ сделать MinValue/MaxValue const вместо readonly?

Нет. Однако BCL этого тоже не делает. Например, DateTime.MinValue равно static readonly. Ваш текущий подход для MinValue и MaxValue подходит.

Что касается других двух ваших вопросов - удобство использования и сам шаблон.

Лично я бы избегал автоматических преобразований (операторов неявного преобразования) для типа «температура», подобного этому. Температура не является целочисленным значением (на самом деле, если бы вы собирались сделать это, я бы сказал, что это должно быть число с плавающей запятой — 93,2 градуса C вполне допустимо). Обработка температуры как целого числа , и особенно неявная обработка любого целочисленного значения как температуры кажется неуместной и потенциальной причиной ошибок.

Я считаю, что структуры с неявным преобразованием часто создают больше проблем с удобством использования, чем решают. Заставить пользователя написать:

 Celsius c = new Celcius(41);

На самом деле не намного сложнее, чем неявное преобразование из целого числа. Однако все гораздо яснее.

person Reed Copsey    schedule 07.11.2011
comment
+1 за хорошую запись и предупреждение об использовании int в этом случае. - person JonH; 07.11.2011
comment
Спасибо. Я понимаю вашу точку зрения на неявный оператор для построения. Считаете ли вы столько же проблем с неявным оператором обратного оператора int(Celsius c)? С точки зрения ясности это кажется чрезмерным убийством: int i = (int)c; - person ErnieL; 08.11.2011
comment
@ErnieL На самом деле мне это не нравится - температура не является произвольным числом - я не вижу смысла разрешать неявное преобразование, поскольку оно может вызывать другие проблемы. Я считаю, что принудительное явное преобразование обычно безопаснее в долгосрочной перспективе... - person Reed Copsey; 08.11.2011

Я думаю, что с точки зрения удобства использования я бы выбрал тип Temperature, а не Celsius. Celsius — это просто единица измерения, а Temperature представляет фактическое измерение. Тогда ваш тип может поддерживать несколько единиц измерения, таких как градусы Цельсия, Фаренгейта и Кельвина. Я бы также выбрал десятичное число в качестве резервного хранилища.

Что-то в этом роде:

public struct Temperature
{
    private decimal m_value;

    private const decimal CelsiusToKelvinOffset = 273.15m;

    public static readonly Temperature MinValue = Temperature.FromKelvin(0);
    public static readonly Temperature MaxValue = Temperature.FromKelvin(Decimal.MaxValue);

    public decimal Celsius
    {
        get { return m_value - CelsiusToKelvinOffset; }
    }

    public decimal Kelvin 
    {
        get { return m_value; }
    }

    private Temperature(decimal temp)
    {
        if (temp < Temperature.MinValue.Kelvin)
               throw new ArgumentOutOfRangeException("temp", "Value {0} is less than Temperature.MinValue ({1})", temp, Temperature.MinValue);
        if (temp > Temperature.MaxValue.Kelvin)
               throw new ArgumentOutOfRangeException("temp", "Value {0} is greater than Temperature.MaxValue ({1})", temp, Temperature.MaxValue);
         m_value = temp;
    }

    public static Temperature FromKelvin(decimal temp)
    {     
           return new Temperature(temp);
    }

    public static Temperature FromCelsius(decimal temp)
    {
        return new Temperature(temp + CelsiusToKelvinOffset);
    }

    ....
}

Я бы избегал неявного преобразования, поскольку Рид утверждает, что оно делает вещи менее очевидными. Однако я бы перегрузил операторы (‹, >, ==, +, -, *, /), так как в этом случае имело бы смысл выполнять такие операции. И кто знает, возможно, в какой-то будущей версии .net мы сможем даже указать ограничения операторов и, наконец, сможем написать больше повторно используемых структур данных (представьте себе класс статистики, который может вычислять статистику для любого типа, который поддерживает +, -, *, /).

person ChrisWue    schedule 07.11.2011
comment
+1 для операторов сравнения и базового температурного класса ... Не уверен в свойствах преобразования - вероятно, сработает в некоторых случаях. Вместо этого я бы рассмотрел шаблон ToString/Parse с передачей строки пользовательской единицы измерения (Temperature.ToString("C")). - person Alexei Levenkov; 08.11.2011
comment
@Alexei: Да, пользовательский синтаксический анализ строк на основе единиц измерения работает хорошо, кстати. - person ChrisWue; 08.11.2011
comment
Мне нравятся FromKelvin() и FromCelsius(). Я думаю, что и вы, и @Reed Cospsey правильно указываете, что DateTime - лучшая модель BCL для имитации, чем int для того, что хранится. - person ErnieL; 08.11.2011
comment
+1: На самом деле это очень, очень похоже на некоторые из моих текущих типов (включая тип температуры). Однако я не обязательно согласен с выбором десятичной дроби здесь — у double, скорее всего, есть большой диапазон и точность для большинства применений температуры, поэтому я использовал его. Кроме того, кстати, конструктор, как написано, не будет компилироваться - он пытается сравнить десятичное число с температурой, что потребует неявных преобразований. Я работаю над этим, используя частный const double для значений Min/Max, и использую это для сравнений и для создания структур только для чтения. - person Reed Copsey; 08.11.2011
comment
@Reed: вместо этого вы можете перегрузить ‹ и ›. Я исправил это в коде, хотя. - person ChrisWue; 08.11.2011
comment
@ChrisWue Лично я бы не стал перегружать ‹ и › для сравнения с десятичным/двойным/и т. д. - только для сравнения с другой температурой. Хотя это только мои предпочтения... - person Reed Copsey; 08.11.2011
comment
Да, это может вызвать у вас проблемы, подобные неявному преобразованию. - person ChrisWue; 08.11.2011

DebuggerDisplay полезное прикосновение. Я бы добавил единицу измерения "{m_value} C", чтобы вы могли сразу увидеть тип.

В зависимости от целевого использования вы также можете захотеть иметь общую структуру преобразования в/из базовых единиц в дополнение к конкретным классам. т.е. хранить значения в единицах СИ, но иметь возможность отображать/редактировать на основе культуры, такой как (градусы C, км, кг) и (градусы F, мили, фунты).

Вы также можете проверить единицы измерения F# для дополнительных идей ( http://msdn.microsoft.com/en-us/library/dd233243.aspx ) — обратите внимание, что это конструкция времени компиляции.

person Alexei Levenkov    schedule 08.11.2011

Я думаю, что это прекрасный шаблон реализации для типов значений. Я делал подобные вещи в прошлом, которые сработали хорошо.

Только одно, поскольку Celsius в любом случае неявно преобразуется в/из int, вы можете определить границы следующим образом:

public const int MinValue = -273;
public const int MaxValue = int.MaxValue;

Однако на самом деле между static readonly и const нет никакой практической разницы.

person kprobst    schedule 07.11.2011
comment
Я бы не стал указывать Min/MaxValue как ints. Они должны быть того же типа, в котором они содержатся. Представьте, что DateTime.Min/MaxValue будет иметь тип int — это может показаться довольно странным. Кроме того, это как бы утечка внутренних элементов (тип резервного поля), что не очень хорошо, если вы хотите/нужно изменить свою реализацию. - person ChrisWue; 08.11.2011
comment
Const — это время компиляции. Только для чтения — это время выполнения. В этом случае разница велика, потому что для создания MaxValue только для чтения требуется вызов конструктора, а конструктор зависит от уже определенного MaxValue. Поиск хорошего выхода из этого круга был одной из причин, по которой я разместил этот вопрос. - person ErnieL; 08.11.2011