#c_sharp #generics
Предположим, я хочу описать generic-класс, выполняющий роль калькулятора, таким образом, чтобы он одинаково работал для всех числовых типов. Т.е.: sbyte byte short ushort int uint long ulong float double Сложность задачи заключается в том, что пусть все эти типы и объединяет наличие определения для их экземпляров арифметических операций, однако они не наследуют какого-либо общего интерфейса типа IArithmetical, INumber. А также ограничение where в C# не позволяет нам описать нечто вроде: public static void Add(T A, T B) where T : +, -, *, /, % ... Так что компилятор не может быть уверен в том, что для всех возможных типов, эксплуатирующих метод, определены нужные операции Это приводит к тому, что подобный код: public class Calculator { public T Add(T A, T B) => A + B; public T Sub(T A, T B) => A - B; public T Mul(T A, T B) => A * B; public T Div(T A, T B) => A / B; public T Mod(T A, T B) => A % B; } Увы, но просто невозможно скомпилировать: по описанным выше причинам будет выкинута ошибка CS0019 Так что же делать в данной ситуации? Возможно-ли вообще средствами C# описать generic-класс/метод, который бы мог работать с числами и только с ними?
Ответы
Ответ 1
Я начал этот топик, дабы рассмотреть все известные (мне) способы решения поставленной проблемы и дать максимально развернутый ответ на сей довольно таки частый вопрос Если вдруг Вам известен метод, который я по каким-то причинам не описал в данном посте - напишите, пожалуйста, об этом в комментариях! Итак, поехали! #0.0: Дублирование кода и никаких generic'ов Бесспорно, самым банальным решением, к которому и прибегают все отчаявшиеся, является простое дублирование кода для каждого из типов: public sbyte Add(sbyte A, sbyte B) => (sbyte)(A + B); public byte Add(byte A, byte B) => (byte)(A + B); public short Add(short A, short B) => (short)(A + B); public ushort Add(ushort A, ushort B) => (ushort)(A + B); public int Add(int A, int B) => A + B; public uint Add(uint A, uint B) => A + B; public long Add(long A, long B) => A + B; public ulong Add(ulong A, ulong B) => A + B; public float Add(float A, float B) => A + B; public double Add(double A, double B) => A + B; Плюсы: По крайней мере это работает) Доступны операции только для заданных типов Операции для типов определены во время компиляции (не придется жертвовать временем и прочими ресурсами во время исполнения для создания данных методов) Минусы: Очевидно, что это куча хлама дублирующего кода, которая ломает всякое изящество проекта Если вдруг метод Add немного поменяет свою логику (как бы странно это ни звучало), то переписывать придется каждый из N методов! #0.1: Кодогенерация Чтобы хоть как-то упростить себе жизнь и не писать множество однотипных методов, можно прибегнуть к кодогенерации с помощью встроенного в Visual Studio T4-генератора: Добавим в проект файл Calc.tt по шаблону Text Template (Текстовый шаблон). Запишем в него следующий код: <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> <#@ import namespace="System.Linq" #> <#@ import namespace="System.Text" #> <#@ import namespace="System.Collections.Generic" #> <#@ template debug="false" hostspecific="false" language="C#" #> namespace Calc { public class Calculator { <# // Типы, используемые в методах string[] usingTypes = new[] { "sbyte", "byte", "short", "ushort", "int", "uint", "long", "ulong", "float", "double" }; // Экземпляры некоторых типов перед операцией кастятся к int, так что результат нужно привести обратно HashSetneedCast= new HashSet { "sbyte", "byte", "short", "ushort" }; foreach(string T in usingTypes) { #> public <#=T#> Add(<#=T#> A, <#=T#> B) => <#=(needCast.Contains(T) ? $"({T})(A + B)" : "A + B")#>; <# } #> } } Выхлоп (Calc.cs) будет выглядеть так: namespace Calc { public class Calculator { public sbyte Add(sbyte A, sbyte B) => (sbyte)(A + B); public byte Add(byte A, byte B) => (byte)(A + B); public short Add(short A, short B) => (short)(A + B); public ushort Add(ushort A, ushort B) => (ushort)(A + B); public int Add(int A, int B) => A + B; public uint Add(uint A, uint B) => A + B; public long Add(long A, long B) => A + B; public ulong Add(ulong A, ulong B) => A + B; public float Add(float A, float B) => A + B; public double Add(double A, double B) => A + B; } } Плюсы: См. пункт 0.0) Мы избавились от проблемной модификации логики метода: теперь вся логика сосредоточена в одном месте, так что достаточно отредактировать алгоритм всего один раз, а кодогенератор проделает всю грязную работу за Вас) Минусы: Поддержка подсветки синтаксиса текстовых шаблонов в Visual Studio хромает, мягко говоря. Так что о комфортном коддинге можно забыть) Это до сих пор нежелательное и лишь слегка прикрытое дублирование кода Данный подход был первоначально описан в ответе от Alexander Petrov #1.0: dynamic Так как мы решаем проблему, которая неведома языкам с динамической типизацией, то следующим очевидным решением будет использование dynamic: Тип dynamic включает операции, в которых он применяется для обхода проверки типов во время компиляции. Такие операции разрешаются во время выполнения. Собственно, нам это подходит! Перепишем метод Add таким образом: // Достаточно привести лишь один аргумент к dynamic, // дабы обозначить динамический контекст public T Add(T A, T B) => (T)((dynamic)A + B); И посмотрим, что получилось: Calculator calcInt = new Calculator (); int resultInt = calcInt.Add(19, 23); // 42 Calculator calcSbyte = new Calculator (); sbyte resultSbyte = calcSbyte.Add(19, 23); // 42 Кажется, все чудесно работает! Право, все же вынужден добавить бочку дегтя в эту ложку меда: Помимо того, что разрешение динамического контекста съедает куда больше времени, нежели разрешение статического, так мы ведь не предусмотрели следующей вещи: Кто нам мешает написать так? Calculator calcDate = new Calculator (); DateTime resultDate = calcDate.Add(DateTime.Now, DateTime.Now); Как раз таки никто, так что код спокойно скомпилируется, экземпляр класса будет успешно создан, однако метод Add упадет со следующей ошибкой: Microsoft.CSharp.RuntimeBinder.RuntimeBinderException: Не удается применить оператор + к операндам типа System.DateTime и System.DateTime Как уже упоминалось в самом вопросе, мы не можем ограничить generic-параметры определенным набором типов. Так что придется делать это вручную и в runtime: public class Calculator { // Добавим классу статический инициализатор, который будет отвечать // за проверку валидности типа // (Статический - дабы не проводить проверку несколько раз для одинаковых типов) static Calculator() { // Если тип не является одним из доступных, то сразу же выкинем ошибку, // дав тем самым конечному пользователю понять, что его // действия неправомерны if (!new[] { typeof(sbyte), typeof(byte), typeof(short), typeof(ushort), typeof(int), typeof(uint), typeof(long), typeof(ulong), typeof(float), typeof(double)}.Contains(typeof(T))) throw new NotSupportedException($"Type `{typeof(T).FullName}` isn't supported!"); } // Достаточно привести лишь один аргумент к dynamic, // дабы обозначить динамический контекст public T Add(T A, T B) => (T)((dynamic)A + B); } Плюсы: Мы избавились от дублирования кода Мы наконец добавили в код generic, чтобы для разных типов работали разные предопределенные методы Минусы: "Чудесная" скорость отработки динамического контекста Необходимость проверять допустимость используемого типа уже во время исполнения #2.0: Изменение контекста функции в зависимости от типа Идея данного метода заключается в следующем: Мы не можем просто так переназначить функции во время исполнения в духе: public void A() => ...; ... A = () => Console.WriteLine("Hello, world!"); Однако мы можем переназначать переменные (в том числе и типов делегатов)! Мы можем создать внутреннее поле типа делегата, переназначать его в зависимости от ситуации, а уже публичный метод, будучи неизменным, как раз и будет его эксплуатировать: public class Calculator { static Calculator() { // Инициализируем _add, исходя из типа generic-параметра if (typeof(T) == typeof(sbyte)) _add = castFrom ((x, y) => (sbyte)(x + y)); else if (typeof(T) == typeof(byte)) _add = castFrom ((x, y) => (byte)(x + y)); else if (typeof(T) == typeof(short)) _add = castFrom ((x, y) => (short)(x + y)); else if (typeof(T) == typeof(ushort)) _add = castFrom ((x, y) => (ushort)(x + y)); else if (typeof(T) == typeof(int)) _add = castFrom ((x, y) => x + y); else if (typeof(T) == typeof(uint)) _add = castFrom ((x, y) => x + y); else if (typeof(T) == typeof(long)) _add = castFrom ((x, y) => x + y); else if (typeof(T) == typeof(ulong)) _add = castFrom ((x, y) => x + y); else if (typeof(T) == typeof(int)) _add = castFrom ((x, y) => x + y); else if (typeof(T) == typeof(double)) _add = castFrom ((x, y) => x + y); else // Если тип не является ни одним из доступных, то выкинем ошибку throw new NotSupportedException($"Type `{typeof(T).FullName}` isn't supported!"); Func castFrom(Func f) => (Func )(object)f; } // Инструкция внутри _add будет проинициализированна // в зависимости от типа generic-параметра private static readonly Func _add; // А вот инструкция в самой функции Add всегда одна - вызвать _add) public T Add(T A, T B) => _add(A, B); } Этот метод стоило бы расположить в блоке про "Дублирование кода", однако для многих он все же является менее очевидным, чем dynamic, да и тему можно развить, что показано в следующем блоке) Плюсы: Работать это будет куда быстрее, чем предыдущий метод с использованием динамического контекста Минусы: Мы опять злоупотребляем дублированием кода (опять же, можно совместить сей метод с кодогенерацией) Необходимость проверять допустимость используемого типа уже во время исполнения Данный подход был первоначально описан в ответе от VladD #2.1: Expression: С помощью класса Expression мы можем по узлам собрать нужное нам дерево выражений и скомпилировать его в делегат необходимой сигнатуры, используя при этом базовую идею предыдущего подхода: public class Calculator { static Calculator() { // Эту проверку Вы уже наблюдали) if (!new[] { typeof(sbyte), typeof(byte), typeof(short), typeof(ushort), typeof(int), typeof(uint), typeof(long), typeof(ulong), typeof(float), typeof(double)}.Contains(typeof(T))) throw new NotSupportedException($"Type `{typeof(T).FullName}` isn't supported!"); // Укажем параметры, испоьзуемые в функции ParameterExpression a = Expression.Parameter(typeof(T)); ParameterExpression b = Expression.Parameter(typeof(T)); // Создадим узел сложения заданных параметров BinaryExpression addition = Expression.Add(a, b); // Скомпилируем полученное дерево _add = Expression.Lambda >(addition, a, b).Compile(); } // Инструкция внутри _add будет проинициализированна // в зависимости от типа generic-параметра private static readonly Func _add; // А вот инструкция в самой функции Add всегда одна - вызвать _add) public T Add(T A, T B) => _add(A, B); } Плюсы: Мы снова избавились от дублирования кода По скорости работы это приближено к не-generic реализациям) Минусы: Придется потратить немного ресурсов в runtime на создание метода Необходимость проверять допустимость используемого типа уже во время исполнения Данный подход был первоначально описан в ответе от Pavel Mayorov #3.0: Векторизация У Microsoft есть следующий прекрасный пакет - System.Numerics.Vectors, описание коиего гласит: Обеспечивает аппаратно-ускоренные числовые типы, подходящие для высокопроизводительной обработки и графических приложений. В данном пакете нас интересует тип Vector , которой способен векторизовать входные данные, после чего мы можем применять к полученным векторам нужные нам арифметические операции! Посмотрим на примере: public class Calculator where T : struct { static Calculator() { // Эту проверку Вы уже наблюдали) if (!new[] { typeof(sbyte), typeof(byte), typeof(short), typeof(ushort), typeof(int), typeof(uint), typeof(long), typeof(ulong), typeof(float), typeof(double)}.Contains(typeof(T))) throw new NotSupportedException($"Type `{typeof(T).FullName}` isn't supported!"); } // Создадим векторы на основе заданных значений, // после чего сложим их и вернем 0-вое измерение // результирующего вектора public T Add(T A, T B) => (new Vector (A) + new Vector (B))[0]; } Плюсы: Никакого дублирования кода Высокая производительность (в некоторых тестах векторизация показывает себя даже лучше нативного кода! Какие операции пройдут быстрее - смотрите в данной табличке) Минусы: Необходимость установки дополнительного nuget-пакета Необходимость проверять допустимость используемого типа уже во время исполнения Данный подход был первоначально описан в ответе от VladD #4.0: IL позволит Вам то, чего не позволит C#! Как известно, код любого .NET-языка транслируется в IL-код. Этот факт мы и будем использовать) Напишем такой вот код: int a = 2; int b = 3; int c = a + b; Просмотрев IL-код, созданный для данной цепочки выражений, мы увидим нечто такое: ldc.i4.2 stloc a ldc.i4.3 stloc b ldloc a ldloc b add stloc c (Код примерный, таким он, конечно, не будет. Приведен он в таком виде для ясности происходящего) Что же отвечает за сложение двух чисел типа int? Стандартная инструкция add) Перепишем код: double a = 2; double b = 3; double c = a + b; Теперь IL будет таковым: ldc.r8 2 stloc a ldc.r8 3 stloc b ldloc a ldloc b add stloc c Что изменилось? Только инструкция loadconstant, инструкция же сложения так и осталось на своем законном месте) Я веду к тому, что на уровне IL одна и та же инструкция add спокойненько обрабатывает сложение экземпляров типов sbyte, byte, short, ushort, int, uint, long, ulong, float, double) А ведь это именно то, что нам нужно! (К слову, это верно и для инструкций sub, mul, div, rem. Подробный лист инструкций IL с описанием найдете здесь) Добавим к проекту файл Calc.il, используя расширение ILSupport, после чего запишем туда следующий код: .class Calc.Calculator`1 { .method public !T Add(!T, !T) cil managed { .maxstack 2 ldarg.0 // Кладем на стек нулевой аргумент ldarg.1 // Кладем на стек первый аргумент add // Складываем их ret // Возвращаем результат } } На C# же проделаем следующие манипуляции с классом: public class Calculator { static Calculator() { // Эту проверку Вы уже наблюдали) if (!new[] { typeof(sbyte), typeof(byte), typeof(short), typeof(ushort), typeof(int), typeof(uint), typeof(long), typeof(ulong), typeof(float), typeof(double)}.Contains(typeof(T))) throw new NotSupportedException($"Type `{typeof(T).FullName}` isn't supported!"); } // Сообщаем, что метод реализован где-то в другом месте [MethodImpl(MethodImplOptions.ForwardRef)] public extern T Add(T A, T B); } Вот и готово! Скомпилировав проект, мы получим класс, который способен работать с любым стандартным числовым типом) Плюсы: Никакого дублирования кода Не нужно тратить времени и прочих ресурсов на создание метода в runtime Высокая производительность, совпадающая с таковой нативного кода (ибо это он по сути и есть)) Минусы: Для человека, который не знаком с IL, решение может показаться сложным Необходимо настроить проект на "сожительство" C# и IL Необходимость проверять допустимость используемого типа уже во время исполнения Данный подход был первоначально описан в ответе от Kir_Antipov Надеюсь, один из предложенных в данном ответе методов помог решить Вам указанную задачу) А пока у меня есть 2 большие просьбы: Не забывайте благодарить авторов оригинальных ответов (помимо своего решения я собрал в данном ответе и идеи других участников сообщества, приведя на них ссылки) Если у Вас есть еще идеи по решению данной задачи/по исправлению данного ответа - пишите комментарии! Буду безумно рад выслушать Ваше мнение) Ответ 2
Мне нравятся подходы 0.0 и 0.1 из предыдущего ответа, но дублирование кода я бы сделал по-другому принципу, я бы вынес интерфейс ICalculator: public interface ICalculator { T Add(T a, T b); T Sub(T a, T b); T Mul(T a, T b); T Div(T a, T b); T Mod(T a, T b); } public class IntCalculator : ICalculator { public int Add(int a, int b) => a + b; public int Sub(int a, int b) => a - b; public int Mul(int a, int b) => a * b; public int Div(int a, int b) => a / b; public int Mod(int a, int b) => a % b; } Потребуется реализовать ICalculator для каждого типа с которым вы хотите работать, они не обязательно должны быть числами. Такой подход будет гораздо лучше если вы используете Dependency Injection, вы сможете передавать ICalculator в класс где он требуется. Ну и напоследок - реализация может быть либо такой, либо можно создать универсальную с dynamic или IL. Ответ 3
Все числовые типы объединяет то, что они являются структурами и реализуют интерфейс IComparable. С этим ограничением уже можно отсечь много неподходящих типов на этапе компиляции. Не нужно использовать статические конструкторы для "валидации", они предназначены для инициализации глобального состояния, и класс, единственная задача которого - арифметические операции, вообще не должен их иметь. Проверяйте перед вычислением (или компиляцией выражения), это намного более логично. Что касается алгоритма, есть еще один способ, который лежит на поверхности: это простой обобщенный метод с несколькими ветками в условном операторе. Может показаться, что веток будет слишком много, но на самом деле, операции сложения для многих типов по сути одинаковы и отличаются только типом, к которому приводится конечный результат. Например, операцию сложения на целом типе можно представить как операцию сложения на Decimal с последующим "сужающим" приведением к целому типу (Decimal позволяет представить все значения любых целых типов и еще оставляет некоторый запас для обработки переполнений). Аналогично, сложение на типе float можно представить как сложение на типе double с последующим преобразованием результата. Весь набор числовых типов можно разделить на три группы: Беззнаковые целые. Для них формула преобразования из Decimal в конкретный тип будет выглядеть так: y = x % 2 n где n - размер типа в битах. (Остаток от деления тут появляется, так как по умолчанию у нас unchecked-контекст, и переполнения не генерируют ошибку, а просто обрезаются по границе типа.) Знаковые целые. Для них минимальное значение равно - 0.5 * 2 n, а максимальное 0.5 * 2 n - 1. Пользуясь этим, можно вывести формулу перевода: y = (x + 2 n * 1.5) % 2 n - 0.5 * 2 n На самом деле, формула может выглядеть по разному, но для отлова переполнений подходит именно такой вид. С плавающей точкой. Ну, тут все просто, формула не нужна, так как преобразование из double в float это просто обрезка "знаков после запятой". Реализовать это можно так: using System; using System.Text; namespace ConsoleApp1 { public class Calculatorwhere T : struct,IComparable { static bool IsSignedInteger(Type t) { return (t == typeof(sbyte) || t == typeof(short) || t == typeof(int) || t == typeof(long)); } static bool IsUnsignedInteger(Type t) { return (t == typeof(byte) || t == typeof(ushort) || t == typeof(uint) || t == typeof(ulong)); } static bool IsReal(Type t) { return (t == typeof(float) || t == typeof(double)); } //преобразует значение из Decimal в целевой целочисленный тип public static T FromDecimal(decimal val) { //вычисляем размер типа int size = System.Runtime.InteropServices.Marshal.SizeOf(typeof(T)); //вычисляем количество элементов в целевом множестве decimal capacity = (size < 8) ? (1L << (size * 8)) : ((decimal)UInt64.MaxValue + 1); //отображаем элемент на целевое множество decimal res; if (IsUnsignedInteger(typeof(T))) { res = (val) % (capacity); return (T)Convert.ChangeType(res, typeof(T)); } else if (IsSignedInteger(typeof(T))) { res = (val + capacity * 1.5M) % (capacity) - capacity * 0.5M; return (T)Convert.ChangeType(res, typeof(T)); } else throw new NotSupportedException(typeof(T).ToString() + " is not integer type"); } //непосредственно сложение public static T Add(T A, T B) { if (IsSignedInteger(typeof(T)) || IsUnsignedInteger(typeof(T))) { return FromDecimal(Convert.ToDecimal(A) + Convert.ToDecimal(B)); } else if (IsReal(typeof(T))) { return (T)Convert.ChangeType(Convert.ToDouble(A) + Convert.ToDouble(B), typeof(T)); } else throw new NotSupportedException(typeof(T).ToString() + " is not supported, because it is not numeric type"); } } class Program { static void Main(string[] args) { unchecked { //тест сложения целых чисел Console.WriteLine("{0} {1}", Calculator .Add(1000, 222), (1000 + 222)); Console.WriteLine("{0} {1}", Calculator .Add(200, 200), (byte)(200 + 200)); Console.WriteLine("{0} {1}", Calculator .Add(100, 100), (sbyte)(100 + 100)); Console.WriteLine("{0} {1}", Calculator .Add(long.MinValue, -1), (long)(long.MinValue - 1)); //тест сложения с плавающей точкой Console.WriteLine("{0} {1}", Calculator .Add((float)Math.PI, 2.2f), (float)Math.PI + 2.2f); Console.WriteLine("{0} {1}", Calculator .Add(Math.PI, 2.2), Math.PI + 2.2); //этот код выдаст исключение... //Console.WriteLine("{0}", Calculator .Add(DateTime.Now, new DateTime(2000, 1, 1))); //Console.WriteLine("{0}", Calculator .Add(true, true)); //а этот - не скомпилируется //Console.WriteLine("{0}", Calculator .Add("Саша", "Маша")); } Console.ReadKey(); } } } Если наплевать на переполнения, то код можно значительно упростить.
Комментариев нет:
Отправить комментарий