Создаем тип "Матрица"

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

В прошлом семестре мы уже рассматривали классы. И даже использовали их тремя способами:

  1. Чтобы декомпозировать код и объединить набор функций под одним именем (был у нас класс Logic)
  2. Чтобы поработать с так называемыми массовыми задачами. То есть когда есть алгоритм, который решает произвольную задачу (по сути набор функций класса Logic), но мы к этому алгоритму подмешиваем данные. И в результате у нас получается композиция алгоритма с данными, который представляет собой так называемую индивидуальную задачу.
  3. Ну и, делая курсовую, мы создавали класс для хранения данных. То есть по сути набор переменных, объединённых под одним именем.

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

И так, давайте взглянем на матрицу. Рассматривать будем квадратную.

Казалось бы, бери и используй двумерный массив, чего что-то придумывать. Но нет, нам этого мало. Двумерный массив слишком простая структура, все что с ним можно делать это изменять значения его элементов. Чего совсем недостаточно для полноценной матрицы.

Проще говоря, чтобы запрограммировать матрицу нам придется еще и запрограммировать способы взаимодействия с ней. Ну а что можно делать с матрицами? Ну их можно складывать, вычитать, умножать, на константу еще можно умножать, транспонировать, обратную искать и т.д. Мы конечно все это реализовывать не будем, но операцию сложения очень даже реализуем.

Ну и посмотрим, как все это тестировать.

Создаем консольное приложение

В общем поехали. Создаем консольное приложение. Если забыли как, то вам сюда

namespace ConsoleApp8
{
    class Program
    {
        static void Main(string[] args)
        {

        }
    }
}

добавим новый класс в пространство имен.

namespace ConsoleApp8
{
    public class Matrix // <<< ДОБАВИЛИ
    {

    }

    class Program
    {
        static void Main(string[] args)
        {

        }
    }
}

добавим теперь поле для хранения данных и конструктор. Единственный параметр, который нам нужен что определить матрицу (напомню, она у нас квадратная) есть ее размер, то бишь n.

public class Matrix
{
    public int[,] data; // поле для хранения данных
    private int n; // поле для хранения размера матрицы, приватное, чтобы кто-нибудь извне не изменил
    
    public Matrix(int n)
    {
        this.data = new int[n, n]; // инициализируем матрицу
        this.n = n;
    }
}

Объявляя переменные приватными (используя слово private), я тем самым ограничиваю доступ к ним извне класса. То есть если кто-то создаст экземпляр класса Matrix, он не сможет напрямую обращаться к полю n и менять его. Вот как я его установил в конструкторе, таким оно и будет. Например:

var matrix = new Matrix(5);
Console.WriteLine(matrix.n); // ВЫДАСТ ОШИБКУ

И хотя технически это ни на что не влияет. Семантически я как бы говорю, что эти данные трогать нельзя, класс лучше знает, как с ними работать.

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

public class Matrix
{
    public int[,] data;
    private int n;
    
    public Matrix(int n)
    {
        this.data = new int[n, n];
        this.n = n;
    }

    // свойство которое возвращает количество элементов в матрице
    // я ведь к n доступ закрыл, приходится проксировать через свойство
    public int Size
    {
        get // тут только метод для обращения
        {
            return n; // возвращаем просто размер матрицы
        }
    }
}

Давайте попробуем написать программу где используется это класс. Переключимся на функцию Main и напишем код:

static void Main(string[] args)
{
    var matrix = new Matrix(5);
    Console.WriteLine(matrix.Size);
    
    Console.ReadKey();
}

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

Пока у нас получается пустая матрица

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

var matrix = new Matrix(5);
matrix.data[0,0] = 1;
matrix.data[1,0] = 2;

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

var matrix = new Matrix(5);
matrix[0,0] = 1;
matrix[1,0] = 2;

Такая возможности есть.

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

И так, отредактируем класс Matrix и определим метод для индексации

public class Matrix
{
    public int[,] data;
    private int n;
    
    public Matrix(int n)
    {
        this.data = new int[n, n];
        this.n = n;
    }
    
    // Сия хитрая конструкция позволяет реализовать свою индексацию
    // двумерную между прочим, видите два аргумента i и j
    public int this[int i, int j]
    {
        get // обращение, например если кто-то пишет var k = matrix[1,1];
        {
            return data[i, j]; // проксируем данные из поля data
        }
        set // присваивание, вызывается если кто-то пишет matrix[1,1] = 2;
        {
            data[i, j] = value; // проксируем присвоенное значение в data[i,j]
        }
    }
    
    public int Size
    {
        // ...
    }
}

Ну вот теперь матрицу можно заполнять:

class Program
{
    static void Main(string[] args)
    {
        // объявил матрицу
        var matrix = new Matrix(5);
        
        // заполнили
        for(var i=0;i<matrix.Size;++i)
        {
            for (var j=0;j<matrix.Size;++j)
            {
                // чтобы элементы поинтереснее были
                // рассчитаем чего-нибудь
                matrix[i, j] = (i + 1) * (j + 1);
            }
        }
        
        // а теперь выведем ее в консоль
        for(var i=0;i<matrix.Size;++i)
        {
            for (var j=0;j<matrix.Size;++j)
            {
                Console.Write("{0}\t", matrix[i,j]);
            }
            Console.WriteLine();
        }
        Console.ReadKey();
    }
}

получится

Добавляем метод для печати и заполнения случайными числами

А давайте добавим еще метод, который будет выводить матрицу в консоль и метод для заполнения матрицы случайными числами:

public class Matrix
{
    //...
    
    public void Print()
    {
        for (var i = 0; i < this.Size; ++i)
        {
            for (var j = 0; j < this.Size; ++j)
            {
                // кстати тут появляется интересный способ обращения к индексатору
                // внутри класс, прям через слово this, 
                // немного странно выглядит, но зато очень кратко
                // в нашем случае можно в принципе писать this.data[i,j]
                Console.Write("{0}\t", this[i, j]);
            }
            Console.WriteLine();
        }
    }   
    
    // заполнение случайными числами от 0 до 100
    public void RandomFill()
    {
        var rand = new Random();
        for (var i = 0; i < this.Size; ++i)
        {
            for (var j = 0; j < this.Size; ++j)
            {
                this[i, j] = rand.Next() % 100;
            }
        }
    }
    
    public int Size
    {
        // ...
    }
}

и можно будет переписать код функции Main так:

class Program
{
    static void Main(string[] args)
    {
        var matrix = new Matrix(5);
        
        // заполнили
        matrix.RandomFill();
        
        // вывели
        matrix.Print();
    }
}

Кратко и понятно.

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

Добавляем функцию сложения матриц

И так что есть результат сложения двух матриц? Очевидно, это новая матрица тех же размеров, что и исходная, но где соответствующие элементы сложены. Добавим функцию, которая будет добавлять к исходной матрице другую матрицу и возвращать новую. Правим класс Matrix

public class Matrix
{
    //...
    
    
    // функция которая возвращает тип Matrix, 
    // а на входе требует переменную secondMatrix типа Matrix
    public Matrix Add(Matrix secondMatrix)
    {
        // создаем матрицу под результат, 
        // она того же размера что и матрица к которой прибавляют
        var resultMatix = new Matrix(this.Size);
        
        // последовательно проходим по всем элементам
        for (var i = 0; i < this.Size; ++i)
        {
            for (var j = 0; j < this.Size; ++j)
            {
                // this[i, j] -- это значение i,j-го элемента текущей матрицы
                // secondMatrix[i, j] -- это i,j-ый элемент второй матрицы
                //
                // ну и ясно что i,j-ый элемент результирующей матрицы 
                // рассчитываем, как сумму двух соответствующих элементов
                resultMatix[i, j] = this[i, j] + secondMatrix[i, j];
            }
        }

        return resultMatix;
    }
    
    public int Size
    {
        // ...
    }
}

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

static void Main(string[] args)
{
    // создали матрицу А и заполнили ее
    var matrixA = new Matrix(5);
    matrixA.RandomFill();

    // создали матрицу B и заполнили ее
    var matrixB = new Matrix(5);
    matrixB.RandomFill();
    
    // выводим матрицу А
    Console.WriteLine("Matrix A:");
    matrixA.Print();

    // выводим матрицу B
    Console.WriteLine("\nMatrix B:");
    matrixB.Print();

    Console.ReadKey();
}

увидим что-то в этом роде:

А теперь давайте проверим, как работает наша функция сложения:

static void Main(string[] args)
{
    var matrixA = new Matrix(5);
    matrixA.RandomFill();

    var matrixB = new Matrix(5);
    matrixB.RandomFill();

    Console.WriteLine("Matrix A:");
    matrixA.Print();

    Console.WriteLine("\nMatrix B:");
    matrixB.Print();

    // рассчитываем матрицу сумму
    var matrixSum = matrixA.Add(matrixB);
    // печатаем ее
    Console.WriteLine("\nA + B:");
    matrixSum.Print();

    Console.ReadKey();
}

проверяем:

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

var matrixSum = matrixA.Add(matrixB);

кажется, слишком навороченный. Не правда ли, было бы лучше если б можно было написать код в виде:

var matrixSum = matrixA + matrixB;

и C# позволяет нам это сделать:

Переопределяем оператор сложения

Функция для переопределения (или т.н. перегрузки) оператора должна быть задана в следующем виде

public static Matrix operator + (Matrix A, Matrix B)

собственно, что здесь и зачем попытается рассказать схема:

И вроде если кое-как все понятно, то можно добавить оператор в класс Matrix

public class Matrix
{
    //...

    /* ЭТУ ФУНКЦИЮ МОЖНО УБРАТЬ, ОНА БОЛЬШЕ НЕ НУЖНА
    public Matrix Add(Matrix secondMatrix)
    {
      // ...
    }
    */
    
    // ОПРЕДЕЛИЛИ ОПЕРАЦИЮ СЛОЖЕНИЯ ДЛЯ МАТРИЦ
    public static Matrix operator + (Matrix A, Matrix B)
    {
        var resultMatix = new Matrix(A.Size);
        for (var i = 0; i < A.Size; ++i)
        {
            for (var j = 0; j < A.Size; ++j)
            {
                resultMatix[i, j] = A[i, j] + B[i, j];
            }
        }
        return resultMatix;
    }

    //...
}

вернемся к нашей функции Main и подправим код, так чтобы использовалась операция “+”

static void Main(string[] args)
{
    var matrixA = new Matrix(5);
    matrixA.RandomFill();

    var matrixB = new Matrix(5);
    matrixB.RandomFill();

    Console.WriteLine("Matrix A:");
    matrixA.Print();

    Console.WriteLine("\nMatrix B:");
    matrixB.Print();

    // рассчитываем матрицу сумму
    var matrixSum = matrixA + matrixB;
    // печатаем ее
    Console.WriteLine("\nA + B:");
    matrixSum.Print();

    Console.ReadKey();
}

проверяем:

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

Добавление числа к матрице

Введем оператор добавления числа к матрице. И хотя формальная математика не позволяет нам это сделать. Мы можем рассмотреть эту операцию как добавления единичной матрицы со всеми элементы равными константе к исходной матрице. Т.е. как-то так:

\[\begin{pmatrix}1 & 2 & 3\\\ 4 & 5 & 6\\\ 7 & 8 & 9\end{pmatrix} + 3 = \begin{pmatrix}1 & 2 & 3\\\ 4 & 5 & 6\\\ 7 & 8 & 9\end{pmatrix} + \begin{pmatrix}3 & 0 & 0\\\ 0 & 3 & 0\\\ 0 & 0 & 3\end{pmatrix} = \begin{pmatrix}4 & 2 & 3\\\ 4 & 8 & 6\\\ 7 & 8 & 12\end{pmatrix}\]

отредактируем наш класс Matrix:

public class Matrix
{
    // ...

    public static Matrix operator + (Matrix A, Matrix B)
    {
        // это наша операция сложения двух матриц, мы ее не трогаем
        // ...
    }

    // ТУТ ОБРАТИТЕ ВНИМАНИЕ НА ПАРАМЕТР int c, то есть операция Матрица + число
    public static Matrix operator +(Matrix A, int c)
    {
        var resultMatix = new Matrix(A.Size);
        for (var i = 0; i < A.Size; ++i)
        {
            for (var j = 0; j < A.Size; ++j)
            {
                if (i == j) // если элемент на диагонали, то прибавляем c
                {
                    resultMatix[i, j] = A[i, j] + c;
                }
                else // а если нет, то просто присваиваем значение исходной матрицы
                {
                    resultMatix[i, j] = A[i, j];
                }
            }
        }
        return resultMatix;
    }

   // ...
}

и код функции Main

static void Main(string[] args)
{
    var A = new Matrix(5);
    A.RandomFill();

    Console.WriteLine("Matrix A:");
    A.Print();

    // рассчитываем матрицу и печатаем ее
    var matrixSum = A + 3;
    Console.WriteLine("\nA + 3:");
    matrixSum.Print();

    Console.ReadKey();
}

проверяем:

красота.

А что если мы захотим сложить наоборот, число + Матрица, поменяем

var matrixSum = A + 3;

на

var matrixSum = 3 + A;

а фиг вам, такой код даже не скомпилируется:

И в этом есть доля логики, не все операции коммутативны, то же умножение матрицы. А в программировании все строго.

В общем чтобы поправить эту ситуацию, надо добавить еще одно переопределение операции +, с обратным порядком аргументов:

public class Matrix
{
    // ...

    public static Matrix operator + (Matrix A, Matrix B)
    {
        // ...
    }

    
    public static Matrix operator +(Matrix A, int c)
    {
        // ...
    }
    
    // СРАВНИТЕ ПОРЯДОК АРГУМЕНТОВ ЭТОЙ ФУНКЦИИ И ФУНКЦИИ ВЫШЕ [он обратный]
    public static Matrix operator +(int c, Matrix A)
    {
        // нет необходимости писать кучу кода из функции выше
        // мы просто вызовем сложение с правильным порядком аргументов
        // и автоматом вызовется функция выше
        return A + c;
    }

   // ...
}

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

Тестирование

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

Так же было бы удобно если б матрицу можно было инициализировать с двумерного массива.

Собственно, с этой операции и начнем.

Перегрузка конструктора

Сейчас у нас есть конструктор, которому передаешь целое число n, и он создает матрицу размером \(n\times n\).

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

Когда несколько функций имеют одно название, но разные аргументы, то говорят, что функция перегружена (англ. overload). Собственно, мы перегрузкой и занимались, когда операцию сложения реализовывали. Там мы операцию сложения аж три раза перегрузили.

И так добавим второй конструктор

public class Matrix
{
    private int[,] data;
    private int n;
    
    // ЭТОТ КОНСТРУКТОР НЕ ТРОГАЕМ
    public Matrix(int n)
    {
        this.data = new int[n, n];
        this.n = n;
    }
    
    // А ТУТ ДОБАВЛЯЕМ НОВЫЙ, С ПАРАМЕТРОМ ДВУМЕРНЫМ МАССИВОМ
    public Matrix(int[,] inputData)
    {
        // мы надеемся, что передали квадратную матрицу
        // и берем за размер количество строк
        this.n = inputData.GetLength(0);
        
        // инициализируем внутренний двумерный массив 
        this.data = new int[this.n, this.n];

        // копируем элементы, поэлементно
        for (var i=0; i< this.n; ++i)
        {
            for (var j=0;j< this.n; ++j)
            {
                this.data[i, j] = inputData[i, j];
            }
        }
    }
    
    //...
}

теперь можно проверить как оно работает:

static void Main(string[] args)
{
    var A = new Matrix(new int[,]{ { 1, 2, 3 }, { 4, 5, 6 }, {7,8,9} });

    Console.WriteLine("Matrix A:");
    A.Print();
    
    var matrixSum = 3 + A;
    Console.WriteLine("\nA + 3:");
    matrixSum.Print();

    Console.ReadKey();
}

получим:

Пробуем тестировать

Создадим тест. Для этого по старинке тыкаем где-нибудь правой кнопкой внутри класса Matrix и выбираем “Создание модульных тестов”

далее ОК

видим:

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

namespace ConsoleApp8.Tests
{
    [TestClass()]
    public class MatrixTests
    {
        [TestMethod()]
        public void MatrixTest()
        {
            Assert.Fail();
        }

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

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

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

нам столько тестить лень, поэтому убираем все лишнее и оставляем только один тест:

// ...

namespace ConsoleApp8.Tests
{
    [TestClass()]
    public class MatrixTests
    {
        [TestMethod()]
        public void Test1()
        {
            Assert.Fail();
        }
    }
}

Так как тестирование как правило сводится к проверке на равенство одних объектов другим, то попробуем создать две одинаковый матрицы и сравнить их:

[TestMethod()]
public void Test1()
{
    var A = new Matrix(new int[,] { { 1, 2 }, { 3, 4 } });
    var B = new Matrix(new int[,] { { 1, 2 }, { 3, 4 } });

    Assert.AreEqual(A, B);
}

запускаем:

и плак-плак:

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

Вообще, два объекта получаются неравными потому что C# по умолчанию сравнивает адреса в памяти. А так как два объекта имея даже одинаковые данные по сути находятся в разных местах памяти, то и о никаком равенстве и речи быть не может.

Короче, чтобы решить эту проблему надо переопределить операцию сравнения то бишь равенства двух матриц.

Переопределение операции равенства

Переопределить равенства немного отличается от переопределения операций. Для этого надо переопределить функцию Equals, она в отличие от операторов сложения/вычитания и т.д. имеет абсолютно конкретный аргумент.

В общем делается это так

public class Matrix
{
    //...
    
    public static Matrix operator +(int c, Matrix A) { ... }
    
    /**
     * метод Equals допускает сравнение с любым объектом поэтому и тип аргумента object
     * а сравниваем всегда с текущим объектом поэтому только одни аргумент
     */
    public override bool Equals(object obj)
    {
        // тут мы пытаемся преобразовать переданный объект в матрицу
        var B = obj as Matrix;
        
        // если преобразование не удалось, то есть если B не матрица, то в B окажется null
        if (B == null)
            return false; // а если не матрица, то значит точно не совпадает, возвращаем false

        // а если B оказалось матрицей поэлементно сравниваем элементы
        for (var i = 0; i < this.Size; ++i)
        {
            for (var j = 0; j < this.Size; ++j)
            {
                //  ищем первый несовпавший элемент
                if (this[i, j] != B[i, j])
                    return false; // если найдем, значит не совпадают
            }
        }
        return true; // ну а если такого не найдем, значит совпадает, возвращает true
    }
    
    //...
}

попробуем теперь запустить тест еще раз:

красота! =)

ИТОГО

Что мы должны по идее были вынести из этой статьи:

  1. Можно создавать свои типы объектов как комбинацию простейших (чисел, массивов и т.д.)
  2. Можно создавать свойства, которые будут просто возвращать значения (см. свойство Size)
  3. Можно реализовать собственную индексатор, чтобы сделать использование своего типа более приятным (смотри public int this[int i, int j])
  4. Можно определять свои операции сложения, умножения и т.п. опять же для бы сделать ваш тип юзерфрендли.
  5. Операции, конструкторы и функции можно определять по несколько раз с разным набором аргументов, это зовется перегрузкой.
  6. Чтобы тестировать свои типы придется переопределять функцию Equals.