Создаем свой тип, часть 1

Задача:

  1. Создать класс реализующий операции в соответствии с заданием
  2. Протестировать операции
  3. Создать GUI приложение, а-ля калькулятор

для следующего задания:

0

Мера длины, задаваемая в виде пары (значение, тип), допустимые типы: метры, км, астрономические единицы, парсеки

  • сложение
  • вычитание
  • умножение на число
  • сравнение двух объемов
  • вывод значения в любом типе

Создаем GUI приложение

Выбираем Файл / Создать / Проект и выбираем WindowsForms приложение

видим пустую форму

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

Переключимся на код формы (жмем F7) и увидим

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace WindowsFormsApp10
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }
    }
}

Добавляем класс длина

Мы раньше класс пихали прямо в файл с формой или в файл с точкой входа. Это в общем это так себе грамотный подход, так что теперь будем действовать грамотно и создавать наш класс в отдельном файле.

И так, тыкаем правой кнопкой мыши на называние проекта и выбираем Класс…

прописываем имя класса Length и жмем Добавить

Увидим вот это

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace WindowsFormsApp10
{
    class Length
    {
    }
}

заменим

class Length

на

public class Length

Добавляем конструктор

У нас свой тип, который есть комбинация более простых типов. Со значением все просто – это число, пусть будет типа double, а вот с мерой сложнее. Давайте для начала просто будем хранить меру как строку, типа

  • “m” – измеряем в метрах
  • “km” – измеряем в километрах
  • “au” – астрономическая единица (англ. astronomical unit)
  • “pc” – парсек

у нас потребуется два поля:

  • double value – под значение
  • string type – под тип меры

добавляем

public class Length
{
    private double value;
    private string type;

    public Length(double value, string type)
    {
        this.value = value;
        this.type = type;
    }
}

теперь давайте добавим метод, который будет выводить нам значение в читаемом виде:

public class Length
{
    private double value;
    private string type;

    public Length(double value, string type)
    {
        this.value = value;
        this.type = type;
    }
    
    // выводит значение в виде (1 km, 2 m, 3 au и т.д.)
    public string Verbose() {
       return String.Format("{0} {1}", this.value, this.type);
    }
}

Приступаем к проверке кода

Так как наше приложение не консольное, но тратить время и пускаться во все тяжкие воюя с интерфейсом лениво. Поэтому мы просто создадим тесты и будем проверять как у нас все работает на тестах.

С небольшой натяжкой такой подход можно назвать TDD (test-driven development)

то есть мы сначала опробуем наш код на тестах, постепенно дорабатывая, а потом уже будем подключать его к интерфейсу

И так, тыкаем где-нибудь в центре класса Length правой кнопкой мыши и выбираем Создание модульных тестов

жмем Ok

получаем что-то в этом роде

using Microsoft.VisualStudio.TestTools.UnitTesting;
using WindowsFormsApp10;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace WindowsFormsApp10.Tests
{
    [TestClass()]
    public class LengthTests
    {
        [TestMethod()]
        public void LengthTest()
        {
            Assert.Fail();
        }

        [TestMethod()]
        public void VerboseTest()
        {
            Assert.Fail();
        }
    }
}

уберем тест LengthTest, и оставим только VerboseTest

[TestClass()]
public class LengthTests
{
    [TestMethod()]
    public void VerboseTest()
    {
        Assert.Fail();
    }
}

Согласно его имени, он должен проверять как работает функция Verbose, поэтому добавим строки для создания экземпляра нашего класса (он же тип), и проверим что вид работает корректно

[TestMethod()]
public void VerboseTest()
{
    var length = new Length(10, "m");
    Assert.AreEqual("10 m", length.Verbose());
}

запускаем:

красота:

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

[TestMethod()]
public void VerboseTest()
{
    var length = new Length(38, "попугаев");
    Assert.AreEqual("38 попугаев", length.Verbose());
}

и если запустить этот код прекрасно выполнится и тест будет зелененький.

“Ну так это здорово” – скажете вы. И я частично с вами соглашусь. Но проблема в том, что тот, кто будет использовать наш класс, сможет указать произвольный тип. А потом, когда придет момент складывать попугаев с астрономическими единицами, то будет абсолютно не понятно что делать. Поэтому было б неплохо как-нибудь ограничить список допустимых мер.

Для этого в C# придумали особое ключевое слово enum, который позволяет объявить так называемые перечисляемые значения.

Объявим перечисляемое значения для меры. Переключимся на файл Length.cs, где лежит наш класс и добавим туда:

// добавил перечислимое значение
public enum MeasureType {m, km, au, ps};

public class Length
{
    private double value; 
    private MeasureType type; // << тут заменил string на MeasureType

    public Length(double value, MeasureType type) // << и тут тоже заменил string на MeasureType
    {
        this.value = value;
        this.type = type;
    }

    public string Verbose() {
       return String.Format("{0} {1}", this.value, this.type);
    }
}

и теперь переключимся на код тестов и подправим:

[TestClass()]
public class LengthTests
{
    [TestMethod()]
    public void VerboseTest()
    {
        var length = new Length(38, MeasureType.m);
        Assert.AreEqual("38 m", length.Verbose());
    }
}

давайте теперь сделаем, чтобы вместо 38 m выводилось по-русски типа 38 метров/километров и т. д. Согласно каноничному TDD, сначала пишется тест, а потом код. Я сам в своей практике, предпочитаю делать эта параллельно. Но в этот раз давайте все-таки сначала поправим тест. И так:

[TestMethod()]
public void VerboseTest()
{
    // тестируем все четыре типа
    var length = new Length(38, MeasureType.m);
    Assert.AreEqual("38 м.", length.Verbose());

    length = new Length(38, MeasureType.km);
    Assert.AreEqual("38 км.", length.Verbose());

    length = new Length(38, MeasureType.au);
    Assert.AreEqual("38 а.е.", length.Verbose());

    length = new Length(38, MeasureType.ps);
    Assert.AreEqual("38 парсек", length.Verbose());
}

если запустить, то увидим ошибку:

ошибка нам не нужна, так что давайте код функции Verbose поправим, переключаемся на Length.cs и правим метод.

Немного колдунства, одна из прелестей enum типов в том, что они отслеживаются компилятором, а, следовательно, для них работает автоподстановка. И можно творить такие чудеса:

ну а теперь редактируем код

public string Verbose() {
    string typeVerbose = "";
    switch (this.type)
    {
        case MeasureType.m:
            typeVerbose = "м.";
            break;
        case MeasureType.km:
            typeVerbose = "км.";
            break;
        case MeasureType.au:
            typeVerbose = "а.е.";
            break;
        case MeasureType.ps:
            typeVerbose = "парсек";
            break;
    }
    return String.Format("{0} {1}", this.value, typeVerbose);
}

запускаем тесты:

если все сделано корректно, то увидим пройденный результат:

Добавляем операции

Операция сложение с числом

Начнем с простого, с добавления числа. Операция будет работать по следующему правилу: добавление числа к расстоянию увеличивает значение на это число. Например:

  • 1 м + 6.25 = 7.25м
  • 1а.е. + 4 = 5а.е.

Давайте сначала добавим тест:

[TestClass()]
public class LengthTests
{
    [TestMethod()]
    public void VerboseTest()
    {
        // ...
    }

    [TestMethod()]
    public void AddNumberTest()
    {
        var length = new Length(1, MeasureType.m);
        length = length + 4.25;
        Assert.AreEqual("5.25 м.", length.Verbose());
    }
}

очевидно, что запустить эти тесты мы не сможем, потому что операции сложения с числом у нас пока нет. Поэтому давайте ее добавим, снова идем в файл Length.cs

public class Length
{
    //... 

    public static Length operator+(Length instance, double number)
    {
        // расчитываем новую значение
        var newValue = instance.value + number;
        // создаем новый экземпляр класса, с новый значением и типом как у меры, к которой число добавляем
        var length = new Length(newValue, instance.type);
        // возвращаем результат
        return length;
    }
    
    // чтобы можно было добавлять число также слева
    public static Length operator+(double number, Length instance)
    {
        // вызываем с правильным порядком аргументов, то есть сначала длина потом число
        // для такого порядка мы определили оператор выше
        return instance + number;
    }
}

запускаем тесты:

красота!

Операции вычитания, умножения, деления с числом

Короче тут все просто, просто значки меняем у функций. По итогу вот такой код добавляем в класс Length

public class Length
{
    //... 

    // умножение
    public static Length operator *(Length instance, double number)
    {
        // мне лень по три строчки писать, поэтому я сокращаю код до одной строки
        return new Length(instance.value * number, instance.type); ;
    }

    public static Length operator *(double number, Length instance)
    {
        return instance * number;
    }

    // вычитание
    public static Length operator -(Length instance, double number)
    {
        return new Length(instance.value - number, instance.type); ;
    }

    public static Length operator -(double number, Length instance)
    {
        return instance - number;
    }

    // деление
    public static Length operator /(Length instance, double number)
    {
        return new Length(instance.value / number, instance.type); ;
    }

    public static Length operator /(double number, Length instance)
    {
        return instance / number;
    }
}

И такие тесты:

[TestClass()]
public class LengthTests
{
    //...
    
    [TestMethod()]
    public void SubNumberTest()
    {
        var length = new Length(3, MeasureType.m);
        length = length - 1.75;
        Assert.AreEqual("1.25 м.", length.Verbose());
    }

    [TestMethod()]
    public void MulByNumberTest()
    {
        var length = new Length(3, MeasureType.m);
        length = length * 3;
        Assert.AreEqual("9 м.", length.Verbose());
    }

    [TestMethod()]
    public void DivByNumberTest()
    {
        var length = new Length(3, MeasureType.m);
        length = length / 3;
        Assert.AreEqual("1 м.", length.Verbose());
    }
}

запускаем:

Преобразование в другой тип

Прежде чем сразу начать писать операцию сложения длин, заданных в разных типах, лучше сначала написать функцию, которая позволит конвертировать один тип в другой.

Самый простой способ — это преобразовывать в какой-нибудь самый привычный тип (например, метры), а затем преобразовывать дальше, и так:

  • 1 км == \(1000\) метров
  • 1 а.е. == \(149\;597\;870\;700\) м
  • 1 парсек == \(3.0856776⋅10^{16}\) м (блин, и зачем я парсеки взял… ну ладно лень уже переписывать все)

добавляем метод, который будет называться To (можно было назвать ConvertTo, но это сильно многословно).

Добавим функцию в класс Length

public class Length
{
    //... 
        
    //новая функция, возвращает тип Length, по имени To, на вход подается тип newType
    public Length To(MeasureType newType)
    {
        // по умолчанию новое значение совпадает со старым
        var newValue = this.value;
        // если текущий тип -- это метр
        if (this.type == MeasureType.m)
        {
            // а теперь рассматриваем все другие ситуации
            switch (newType)
            {
                // если конвертим в метр, то значение не меняем
                case MeasureType.m:
                    newValue = this.value;
                    break;
                // если в км.
                case MeasureType.km:
                    newValue = this.value / 1000;
                    break;
                // если в  а.е.
                case MeasureType.au:
                    newValue = this.value / 149597870700;
                    break;
                // если в парсек
                case MeasureType.ps:
                    newValue = this.value / (3.0856776 * Math.Pow(10, 16));
                    break;
            }
        }
        return new Length(newValue, newType);
    }
}

добавим тест для проверки конвертации из метра в другие величины:

[TestClass()]
public class LengthTests
{
    //...
    
    [TestMethod()]
    public void MeterToAnyTest()
    {
        Length length;
    
        length = new Length(1000, MeasureType.m);
        Assert.AreEqual("1 км.", length.To(MeasureType.km).Verbose());
    
        length = new Length(149597870700 * 2, MeasureType.m);
        Assert.AreEqual("2 а.е.", length.To(MeasureType.au).Verbose());
    
        length = new Length(3 * 3.0856776 * Math.Pow(10, 16), MeasureType.m);
        Assert.AreEqual("3 парсек", length.To(MeasureType.ps).Verbose());
    }
}

запускаем, проливаем слезы счастья:

и идем дальше.

Давайте подумаем, нам надо уметь преобразовывать из любого типа в любой другой, то есть с учетом 4 типов, и преобразование из себя в самого себя мы считаем за конвертацию. Нам надо добавить еще 3 * 4 case конструкций. И в каждом правильно все рассчитать.

Иначе говоря, перспектива безрадостная. Поэтому воспользуемся головой, и вместо того чтобы решать задачу 12 раз воспользуемся древним математическим подходом: сведения задачи к предыдущей.

То есть вместо того чтобы пытаться понять сколько астрономических единиц в 1.176 парсеках, и долго и нудно считать это на бумажке, мы преобразуем парсеки в метры, а потом уже метры в астрономические единицы. То есть таким образом вместо того чтобы добавлять 12 различных случаев нам придется добавить только 4.

И так, добавляем:

public Length To(MeasureType newType)
{
    var newValue = this.value;
    if (this.type == MeasureType.m)
    {
        // ...
    }
    else if (newType == MeasureType.m) // если новый тип: метр
    {
        switch (this.type) // а тут уже старый тип проверяем
        {
            case MeasureType.m:
                newValue = this.value;
                break;
            case MeasureType.km:
                newValue = this.value * 1000; // кстати это то же код что и выше, только / заменили на *
                break;
            case MeasureType.au:
                newValue = this.value * 149597870700; // и тут / на *
                break;
            case MeasureType.ps:
                newValue = this.value * (3.0856776 * Math.Pow(10,16)); // и даже тут, просто / на *
                break;
        }
    }
    return new Length(newValue, newType);
}

мы уже почти все, добавляем тест:

[TestClass()]
public class LengthTests
{
    //...
    [TestMethod()]
    public void AnyToMeterTest()
    {
        Length length;

        length = new Length(1, MeasureType.km);
        Assert.AreEqual("1000 м.", length.To(MeasureType.m).Verbose());

        length = new Length(1, MeasureType.au);
        Assert.AreEqual("149597870700 м.", length.To(MeasureType.m).Verbose());

        length = new Length(1, MeasureType.ps);
        Assert.AreEqual("3.0856776E+16 м.", length.To(MeasureType.m).Verbose());
    }
}

вы не думайте, что я сразу понял, что 1 парсек выведется как 3.0856776E+16 м, я запустил тест с неправильным значением и просто глянул что выдалось в ошибке:

запускаем и радуемся:

ну и последний момент остается добавить преобразование из любого типа в любой другой. Для этого нам понадобится добавить всего пару строчек. То есть если мы преобразуем не из метров и не в метры. То тогда преобразуй текущее значение сначала в метр, а потом уже в новый тип:

public Length To(MeasureType newType)
{
    var newValue = this.value;
    if (this.type == MeasureType.m)
    {
        // ...
    }
    else if (newType == MeasureType.m)
    {
        // ...
    }
    else // то есть не в метр и не из метра
    {
        newValue = this.To(MeasureType.m).To(newType).value;
        // в принципе можно сразу написать 
        // return this.To(MeasureType.m).To(newType);
        // но хорошем тоном считается наличие всего одного return в функции
    }
    return new Length(newValue, newType);
}

Операция сложения двух разных длин

Ну и вот и добрались до самого интересного. Будем теперь прибавлять астрономические единицы к парсекам, и километры к астрономическим единицам. В общем как душе будет угодно.

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

  • км + м == км
  • м + км = м
  • а.е. + парске = а.е
  • и т. д.

То есть тип длины определяется первым слагаемым в сумме.

И так, добавляем:

public class Length
{
    // ...      
    
    // сложение двух длин  
    public static Length operator+(Length instance1, Length instance2)
    {
        // то есть у текущей длине добавляем число 
        // полученное преобразованием значения второй длины в тип первой длины
        // так как у нас определен operator+(Length instance, double number)
        // то это сработает как ожидается
        return instance1 + instance2.To(instance1.type).value;
    }

    // вычитание двух длин
    public static Length operator -(Length instance1, Length instance2)
    {
        // тут все тоже, только с минусом
        return instance1 - instance2.To(instance1.type).value;
    }
}

ну и напоследок добавим тесты:

[TestClass()]
public class LengthTests
{
    // ...
    
    [TestMethod()]
    public void AddSubKmMetersTest()
    {   
        var m = new Length(100, MeasureType.m);
        var km = new Length(1, MeasureType.km);

        Assert.AreEqual("1100 м.", (m + km).Verbose());
        Assert.AreEqual("1.1 км.", (km + m).Verbose());

        Assert.AreEqual("0.9 км.", (km - m).Verbose());
        Assert.AreEqual("-900 м.", (m - km).Verbose());
    }
}

и наслаждаемся:

Чет много получилось. Так что на сегодня все. Как построить GUI приложение а-ля калькулятор расскажу во 2-ой части.