Как работать с классами

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

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

На практике часто встречаются задачи, которые для одних и тех же данных требуются рассчитать сразу несколько результатов.

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

Задача

Дан вектор чисел \(a(a_1, a_2, ..., a_n)\). Рассчитать

  • значение суммы этих чисел
  • значение произведения этих чисел
  • рассчитать среднее арифметическое этих чисел

Данную задачу можно было решить так:

namespace ConsoleApplication1
{
    class Program
    {
        static void Main(string[] args)
        {
            var numbers = new int[] {1,2,3,4,5};
            int sum = 0; // сумма
            int product = 1; // произведение
            double average = 0; // среднее

            foreach (var el in numbers)
            {
                sum += el;
                product *= el;
            }
    
            average = (double)sum / numbers.Length;
    
            Console.WriteLine("Сумма {0}", sum);
            Console.WriteLine("Произведение {0}", product);
            Console.WriteLine("Среднее значение {0}", average);
            Console.ReadKey();
        }
    }
}

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

public class Logic
    {
        // Функция для расчета суммы элементов
        public static int getSum(int[] numbers)
        {
            int sum = 0;
            foreach (var el in numbers)
            {
                sum += el;
            }
            return sum;
        }

        // Функция для расчета произведения элементов
        public static int getProduct(int[] numbers)
        {
            if (numbers.Length == 0) // если массив пустой возвращаем ноль
            {
                return 0;
            }

            int product = 1;
            foreach (var el in numbers)
            {
                product *= el;
            }
            return product;
        }

        // Функция для расчета среднего значения
        public static double getAverage(int[] numbers)
        {
            // тут удачно вызовем уже реализованный нами метод, чтобы подсчитать сумму
            var sum = Logic.getSum(numbers); 
            return (double)sum / numbers.Length;
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            var numbers = new int[] {1,2,3,4,5};

            // используем код вынесенный в отдельный класс
            int sum = Logic.getSum(numbers); // сумма
            int product = Logic.getProduct(numbers); // произведение
            double average = Logic.getAverage(numbers); // среднее

            Console.WriteLine("Сумма {0}", sum);
            Console.WriteLine("Произведение {0}", product);
            Console.WriteLine("Среднее значение {0}", average);
            Console.ReadKey();
        }
    }
}

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

Но пока код класса Logic, по сути, представляет собой просто набор функции, которые в силу особенностей языка C# нельзя хранить отдельно от класса (то есть каждая функция должна быть в каком-нибудь классе).

Такое архитектура программы в целом соответствует принципам структурного программирования. В том же языке C или C++, такой код выглядел бы попроще, никаких классов, никаких static-ов – только функции.

Но так как мы используем C#, которые дают нам использовать всю мощь объектно-ориентированного программирования, мы проползем по лестнице абстракции чуть выше.

Массовые и индивидуальные задачи

Немного формальной математики.

И так. В теории алгоритмов есть такое понятие как “Массовая задача” и “Индивидуальная задача”. Например,

  • Решить квадратное уравнение \(ax^2 + bx + c = 0\), это пример “Массовой задачи”.

  • А решить эту же задачу, при a = 1, b = 2, c = 1 это уже пример “Индивидуальной задачи”.

То есть в случае “Массовой задачи”, в задачи присутствует некоторая доля неопределенности, которая не позволяет получить ответ в виде набора констант (строковых, или числовых, или еще каких-то). То есть все знают формулы расчета корней квадратного уравнения, но как известно в зависимости от коэффициентов, полученные значения корней могут принимать какие угодно значения.

В “Индивидуальной задаче” неопределенность отсутствует, и ответ может быть получен строго определенно в виде набора констант.

Еще несколько примеров массовых и индивидуальных задач.

  • Массовая задача: “Сложить числа a + b”, Индивидуальная задача: “сложить 2 + 2”
  • Массовая задача: Подсчитать сумму элементов вектора \(A(a_1,a_2,...a_n)\). Индивидуальная задача: Подсчитать сумму элементов вектора \(A(1,2,3)\)

Как можно наблюдать всякая Массовая задача содержит в себе [бес]конечное количество индивидуальных задач.

Всякую задачу из лабораторной можно и нужно рассматривать как “Массовую задачу”. А тесты, которые мы для нее описываем можно рассматривать как некоторый набор индивидуальных задач.

Комплексная массовая задача

Вспомним как у нас выглядела исходная задача:

Дан вектор чисел \(a(a_1, a_2, ..., a_n)\). Рассчитать

  • значение суммы этих чисел
  • значение произведения этих чисел
  • рассчитать среднее арифметическое этих чисел

Данную задачу можно рассматривать как комплексную массовую задачу.

То есть неопределенность у нас запрятана в фразе “Дан вектор чисел \(a(a_1, a_2, ..., a_n)\)”. А необходимые для расчета значения можно рассматривать как набор массовых подзадач. Эти значения можно рассчитать по следующим формулам:

\(\sum_i^n a_i\) – значение суммы

\(\prod_i^n a_i\) – значение произведения

\(\frac{\sum_i^n a_i}{n}\) – среднее арифметическое

Всякой “комплексной массовой задаче” можно сопоставить “комплексную индивидуальную задачу”.

То есть вариант когда дан вектор \(a(1,2,3)\), для которого надо рассчитать сумму, произведение и среднее значение есть пример “комплексной индивидуальной задаче”.

К чему я все это? А к тому чтобы было бы удобно если бы мы могли объявить в коде программы класс в C#, которые являл бы собой олицетворение индивидуальной задачи. То есть содержал не только логику для расчета значений, но и сам набор значений, для которых он вызывается.

Попытка построить такую сущность есть пример использования объектно-ориентированного подхода в написании приложений. В общем, идем дальше.

Создаем класс для решателя индивидуальных задач

Вспомним код, который у нас был, а точнее, код класса Logic:

public class Logic
{
    // Функция для расчета суммы элементов
    public static int getSum(int[] numbers)
    {
        int sum = 0;
        foreach (var el in numbers)
        {
            sum += el;
        }
        return sum;
    }

    // Функция для расчета произведения элементов
    public static int getProduct(int[] numbers)
    {
        if (numbers.Length == 0) // если массив пустой возвращаем ноль
        {
            return 0;
        }

        int product = 1;
        foreach (var el in numbers)
        {
            product *= el;
        }
        return product;
    }

    // Функция для расчета среднего значения
    public static double getAverage(int[] numbers)
    {
        // тут удачно вызовем уже реализованный нами метод, чтобы подсчитать сумму
        var sum = Logic.getSum(numbers); 
        return (double)sum / numbers.Length;
    }
}

Преобразуем его так, чтобы он мог хранить в себе не только логику, но и данные.

Чтобы класс мог хранить данные внутри него, надо объявить переменные. Переменные, объявленные внутри класса, но вне функций класса называются поля (англ. fields). Делается это так:

public class Logic
{
    int[] numbersField; // объявили поле для хранения исходных данных индивидуальной задачи

    // это старый код, его не трогаем...
    public static int getSum(int[] numbers){ /*...*/}
    public static int getProduct(int[] numbers) { /*...*/ }
    public static double getAverage(int[] numbers) { /*...*/ }
}

Теперь, чтобы была возможность передать данные классы и подцепить их к полю numbers, необходимо создать так называемый конструктор. Конструктор — это особый метод, который имеет то же имя, что и класс (в нашем случае Logic) и находится внутри класса. Добавим его.

public class Logic
{
    int[] numbersField;

    /*
     * Объявили конструктор, 
     * которые в качестве параметра принимает array
     */ 
    public Logic(int[] array)
    {
        // подцепляем массив array к полю numbers
        this.numbersField = array;
    }
    
    // это старый код, его не трогаем...
    public static int getSum(int[] numbers){ /*...*/}
    public static int getProduct(int[] numbers) { /*...*/ }
    public static double getAverage(int[] numbers) { /*...*/ }
}

Возникает закономерный вопрос, а зачем мы это сделали. Какое-то поле добавили… какой-то конструктор. А сделали мы это затем, чтобы избавится от параметров при вызове функций getSum, getProduct, getAverage.

И так, правим наши функции:

public class Logic
{
    int[] numbersField;

    public Logic(int[] array)
    {
        this.numbersField = array;
    }

    public int getSum() // тут убрал ключевое слово static и аргумент
    {
        int sum = 0;
        foreach (var el in this.numbersField) // тут заменил numbers на this.numbersField
        {
            sum += el;
        }
        return sum;
    }

    public int getProduct() // тут убрал ключевое слово static и аргумент
    {
        if (this.numbersField.Length == 0) // тут заменил numbers на this.numbersField
        {
            return 0;
        }

        int product = 1;
        foreach (var el in this.numbersField) // тут заменил numbers на this.numbersField
        {
            product *= el;
        }
        return product;
    }

    public double getAverage()  // тут убрал ключевое слово static и аргумент
    {
        // тут удачно вызовем уже реализованный нами метод, чтобы подсчитать сумму
        var sum = this.getSum(); // тут заменил Logic.getSum(numbers) на this.getSum()
        return (double)sum / this.numbersField.Length;
    }
}

На что стоит обратить внимание:

  • При обращении к полю или методу, принадлежащую тому же классу, в котором находится функция, мы используем ключевое слово this.
    • например this.numbersField – обращение к полю numbersField
    • this.getSum() – вызов функции getSum
  • Убрали ключевое слово static. Сделали это затем, чтобы наши функции могли взаимодействовать с полем numbersField. В тоже время, из-за этого функции больше нельзя будет вызывать через Logic.getSomething.
  • Вообще говоря, использование ключевого слово this опционально, и все this.numbersField можно заменить на numbersField, а this.getSum() на просто getSum(). Но я для использую его, чтобы акцентировать, что это не какой-то сферический numbersField из вакуума, а именно поле numbersField из класса Logic. То есть this внутри любого класса это ссылка на самого себя. Ну и да, this нельзя использовать в функциях, объявленных с ключевым словом static.

Подкорректируем наш класс Program, тут как раз пригодится наш конструктор. Вот что получится:

class Program
{
    static void Main(string[] args)
    {
        var numbers = new int[] {1,2,3,4,5};

        var logic = new Logic(numbers); // создаем экземпляр класса, собственно тут и происходит вызов метода конструктора
        int sum = logic.getSum(); // тут меняем Logic.getSum(numbers)
        int product = logic.getProduct(); // тут меняем Logic.getProduct(numbers)
        double average = logic.getAverage(); // тут меняем Logic.getAverage(numbers)

        Console.WriteLine("Сумма {0}", sum);
        Console.WriteLine("Произведение {0}", product);
        Console.WriteLine("Среднее значение {0}", average);
        Console.ReadKey();
    }
}

Если простая декомпозиция позволяла отделить логику программы от общения с пользователем. То использование класса, позволила нам, в дополнение, намертво привязать данные к логике. И таким образом реализовать парадигму “Индивидуальной задачи”. То есть класс Logic будучи решателем “Массовой задачи” при создании своего экземпляра через:

var logic = new Logic(numbers);

превращается в решатель “Индивидуальной задачи”. Это очень интересная метаморфоза, даже если вам так и не кажется =)

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

В общем, вот такой код:

var logic = new Logic(numbers);
int sum = logic.getSum();
int product = logic.getProduct();
double average = logic.getAverage();

с некоторой точки зрения, куда красивее чем такой:

int sum = Logic.getSum(numbers);
int product = Logic.getProduct(numbers);
double average = Logic.getAverage(numbers);

Как тестировать

А также как раньше, только теперь необходимо создавать экземпляр класса. То есть если по старинке мы тестировали бы нашу программу так:

[TestMethod()]
public void LogicTest()
{
    var numbers = new int[] { 1, 2, 3, 4, 5};
    Assert.AreEqual(15, Logic.getSum(numbers));
    Assert.AreEqual(120, Logic.getProduct(numbers));
    Assert.AreEqual(3, Logic.getAverage(numbers));
}

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

[TestMethod()]
public void LogicTest()
{
    var logic = new Logic(new int[] { 1, 2, 3, 4, 5});
    Assert.AreEqual(15, logic.getSum());
    Assert.AreEqual(120, logic.getProduct());
    Assert.AreEqual(3, logic.getAverage());
}

Итоговый код

Если вы вдруг запутались на каком-то этапе, то вот что у вас должно получиться:

public class Logic
{
    int[] numbersField;

    public Logic(int[] array)
    {
        this.numbersField = array;
    }

    public int getSum()
    {
        int sum = 0;
        foreach (var el in this.numbersField)
        {
            sum += el;
        }
        return sum;
    }

    public int getProduct()
    {
        if (this.numbersField.Length == 0)
        {
            return 0;
        }

        int product = 1;
        foreach (var el in this.numbersField)
        {
            product *= el;
        }
        return product;
    }

    public double getAverage()
    {
        var sum = this.getSum();
        return (double)sum / this.numbersField.Length;
    }
}

class Program
{
    static void Main(string[] args)
    {
        var numbers = new int[] {1,2,3,4,5};

        var logic = new Logic(numbers);
        int sum = logic.getSum();
        int product = logic.getProduct();
        double average = logic.getAverage();

        Console.WriteLine("Сумма {0}", sum);
        Console.WriteLine("Произведение {0}", product);
        Console.WriteLine("Среднее значение {0}", average);
        Console.ReadKey();
    }
}