Pull to refresh

Comments 58

… а вы точно статью закончили?

if ((object)this == obj) return true;

Почему вы не используете ReferenceEquals, который намного читаемее?


var other = obj as Person;

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

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

Это как раз тема следующей статьи. Там поговорим об этом подробно.

А эта-то статья нам зачем нужна в таком объеме?

А вам это кому?

Без шуток, лично вам объективно не нужна — про хеш-коды (включая их особенности) вы знаете, так же как про Equals и проблему оператора as и потомков класса (в разрезе равенства по значению).
И даже возможное недопонимание вопроса приведения к object и использования оператора == вам не помешает — т.к. с помощью Object.ReferenceEquals вы без ошибок и побочных эффектов можете проверять ссылку на null.

А вот сообществу, уверен, нужна — я довольно часто в legacy-проектах наблюдаю ошибки (существующие и вновь добавляемые) в реализациях инфраструктуры, используемой для сравнения и сортировки объектов.

Если хотя бы десяток человек прочтут и заинтересуются, почитают первоисточники, и станут использовать в работе подходы, описанные у Рихтера, в MSDN и прочих best practices, уже хорошо.
Если хотя бы десяток человек прочтут и заинтересуются, почитают первоисточники, и станут использовать в работе подходы, описанные у Рихтера, в MSDN и прочих best practices, уже хорошо.

Для того, чтобы это случилось, нужно (а) объяснять все спорные места и (б) использовать максимально читаемые best practices. Вы же для первого случая говорите "в следующей статье", а второй просто оспариваете.


А бездумное применение "потому что у Рихтера так написано" опасно.

Почему вы не используете ReferenceEquals, который намного читаемее?


Что читаемее, вопрос предпочтений — ни один из способов не считаю хорошим.

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

Возможно, хорошим способом будет pattern matching в C# 7.0: obj is null.

(В своей практике встречал даже такое, когда проверка переменной на null (if (a == null)) приводила к NullReferenceException, т.к. замечательные программисты, реализовав перегруженный оператор, обращались в нем к свойствам операндов для проверки на равенство по значению, не проверив операнды на null.)
Что читаемее, вопрос предпочтений — ни один из способов не считаю хорошим.

В данном случае это как раз весьма очевидно.


(object)this == obj

Здесь два (!) оператора, из которых оба могут быть перегружены, и даже дефолтную семантику второго надо помнить.


Object.ReferenceEquals(this, obj)

Здесь явно написано, что сравнение идет по ссылке, перегрузка невозможна, поведение всегда одно и то же (и правильное).


Так что здесь вопрос читаемости весьма прост.


Но если мы хотим сравнить на null, то нужно сравнивать именно на null, а не вызывать перегруженный оператор

Чего вы тоже не делаете. О чем и речь.

Не очевидно:

https://msdn.microsoft.com/library/53k8ybth.aspx

For predefined value types, the equality operator (==) returns true if the values of its operands are equal, false otherwise. For reference types other than string, == returns true if its two operands refer to the same object.

The () operator cannot be overloaded.

Ну и до кучи:
public static bool ReferenceEquals (Object objA, Object objB) {
    return objA == objB;
}


А если все-таки использовать ReferenceEquals, то с указанием класса: Object.ReferenceEquals(a, b),
т.к. в текущем классе (или одном из предков) ReferenceEquals может быть переопределен (new).

Получается, что приведение к object и использование оператора "==" ("!=") — первично, а метод ReferenceEquals — лишь обертка.

Так что вопросы по-прежнему к дизайну языка/объектной модели, если все так некрасиво.
The () operator cannot be overloaded.

Зато explicit cast вполне себе переопределяем.


Получается, что приведение к object и использование оператора "==" ("!=") — первично, а метод ReferenceEquals — лишь обертка.

Тем не менее, когда написано Object.ReferenceEquals — намерение явно читаемо. А когда приведение и сравнение — нет (нужно именно что помнить дизайн языка).

Зато explicit cast вполне себе переопределяем.

https://msdn.microsoft.com/library/ms173105.aspx
User-defined conversions: User-defined conversions are performed by special methods that you can define to enable explicit and implicit conversions between custom types that do not have a base class–derived class relationship. For more information, see Conversion Operators.

Соответственно, переопределение приведения к типу object невозможно ни для одного типа (это легко проверить в коде).

Кроме того, explicit cast к object происходит статически, на этапе компиляции.

Поэтому проверка вида "(object)value == null" является первичным способом проверки на null.

То, что такая проверка неочевидна, и для ее понимания и запоминания нужно знать ряд неочевидных технических подробностей — плохой дизайн языка, объектной модели и платформы.

Однако, в пользу варианта Object.ReferenceEquals говорит скорее, не большая очевидность, которая на мой взгляд, сомнительна, а то, что CLS-спецификация не обязывает язык поддерживать операторы.
А потому любой оператор должен иметь соответствующий ему метод (и во всех классах стандартной библиотеки так и есть).
Поэтому если задать вопрос «а как проверить в .NET (не в C# или VB, в целом .NET) ссылки на равенство, или ссылку на null», то тут верный ответ будет Object.ReferenceEquals.

Но если мы пишем на C#, то удобно пользоваться всеми его возможностями — а для C# первичным является приведение ссылок к object и использование оператора равенства (иначе ведь придется отказаться от всех операторов, для которых есть методы-дублеры).

Повторюсь, не считаю оба способа удобными и очевидными, и полагаю, что оптимальнее будет pattern matching «value is null» при условии, что конструкция будет внутри реализована именно через проверку ссылки, а не вызов перегруженного оператора.

Кстати, как именно реализованы уже имеющиеся операторы "??" и "?."?
Соответственно, переопределение приведение к типу object невозможно ни для одного типа. Кроме того, explicit cast к object происходит статически, на этапе компиляции.

Да, но об этом всем надо помнить. В Object.ReferenceEquals все явно написано.


То, что такая проверка неочевидна, и для ее понимания и запоминания нужно знать ряд неочевидных технических подробностей — плохой дизайн языка, объектной модели и платформы.

Возможно. Именно поэтому Object.ReferenceEquals — более читаемое решение.


а для C# первичным является приведение ссылок к object и использование оператора равенства

Вот этот тезис ничем (кроме вашего представления о "первичности") не обоснован.


Кстати, как именно реализованы уже имеющиеся операторы "?? и "?."?

Подозреваю, что через ldnull; ceq; в IL, который всегда сравнивает "по ссылке".

Про первичность:

Есть основания подозревать, что сравнение с null переменной, приведенной к object, тоже происходит через ldnull; ceq;

А ReferenceEquals — это в любом случае метод (в данном случае обертка вокруг оператора).
Другое дело, что, возможно (не проверял), один из этих атрибутов:
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
[System.Runtime.Versioning.NonVersionable]
может приводить к тому, что содержимое метода инлайнится при вызове?

В любом случае, оба способа корректны, детерминированы, и не имеют побочных эффектов.
Меня бы устроило, если бы программисты проверяли ссылки на null любым из этих способов.
Есть основания подозревать, что сравнение с null переменной, приведенной к object, тоже происходит через ldnull; ceq;

… и что?


А ReferenceEquals — это в любом случае метод

… и что?


содержимое метода инлайнится при вызове?

Конкретно эти атрибуты не влияют на инлайн (по крайней мере, насколько я знаю). Но JIT может применять любые другие эвристики, чтобы понять, надо ли делать инлайн.


Меня бы устроило, если бы программисты проверяли ссылки на null любым из этих способов.

Вы так и не поняли, что я с самого начала говорю не о сравнении с null, а о сравнении с другим объектом. Первая строчка вашего Equals. И я говорил не о функциональной корректности (я подозреваю, что IL-код в релизе будет просто идентичным), а о читаемости.

неважно, о сравнении с null или другим объектом
речь, конечно, о сравнении ссылок
в строчке, которая вам так не по душе, первый операнд — не object, приведенный статически к object, а второй объявлен object, и его тип не будет меняться — поэтому там приведения нет
таким же образом происходит и сравнение с null — его нет нужды приводить к object (он по умолчанию object, если не приведен к другому типу)

… и что?

Все то же — вопрос первичности.

Если мы говорим о C#, то при сравнении ссылок первично сравнение object'ов через оператор сравнения, а метод ReferenceEquals — обертка, т.е., вторичен, и насчет инлайнинга мы можем только предполагать.

Если говорим о CLS, то вопрос первичности отпадает, единственный кандидат — ReferenceEquals.
поэтому там приведения нет

Там написано приведение.


Если мы говорим о C#, то при сравнении ссылок первично сравнение object'ов через оператор сравнения, а метод ReferenceEquals — обертка, т.е., вторичен

Это только ваше определение "первичности".


Если говорим о CLS, то вопрос первичности отпадает, единственный кандидат — ReferenceEquals.

Меня вообще не волнует "первичность". Меня волнует читаемость.

поэтому там приведения нет

вы же об этой строчке
if ((object)this == obj) return true;
у первого операнда приведение к object есть, у второго нет, т.к. он объявлен как object

Меня вообще не волнует «первичность». Меня волнует читаемость.
Но это ваше предпочтение, что важнее — первичность (производительность) или читаемость, и ваше видение, что более читаемо.

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

Я вроде ничего и не говорил про второй.


Но это ваше предпочтение, что важнее — первичность (производительность)

"Первичность" не имеет никакого отношения к производительности. Единственный корректный способ говорить о производительности — это измерения.


Но это ваше предпочтение, что важнее — первичность (производительность) или читаемость

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

Для двух объектов класса Person a и b, для которых a.FirstName == b.LastName, a.LastName == b.FirstName и a.BirthDate == b.BirthDate, метод GetHashCode() вернет одинаковые значения; правильно ли это?
GetHashCode() вернет для a и b одинаковые значения, если следующее выражение вернет true:
a.FirstName == b.FirstName &&
a.LastName == b.LastName &&
a.BirthDate == b.BirthDate;
UFO just landed and posted this here
хэш-коды по определению допускают коллизию
(и в статье об этом упомянуто)

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

как их использовать конкретно в .NET — можно почитать и в MSDN, и у Рихтера
можно почитать, как происходит работа с хеш-кодами в Java

если в целом про природу хэш-кодов, хэш-сумм в криптографии, контрольных сумм, и т.д. — полно литературы и источников
Для случая
a.FirstName == b.LastName, a.LastName == b.FirstName и a.BirthDate == b.BirthDate

GetHashCode() действительно вернет одинаковые значения, это коллизия — следствие природы хеш-кода, но не ошибка.

Главное, на основании равенства хеш-кодов не делать вывод о равенстве объектов по значению.
Равенство хеш-кодов — повод проверить равенство объектов с помощью Equals.

А вот различие хеш-кодов однозначно говорит о неравенстве объектов.
к чему вел, может стоит перемножать на какое-либо число каждый раз перед xor, дабы если случается такая ситуация, возвращались разные хэши и нам не приходилось лишний раз вызывать Equals? что-то вроде такого:
public override int GetHashCode() {
    unchecked {
        return ((this.FirstName.GetHashCode()*397 ^
            this.LastName.GetHashCode())*397 ^
            this.BirthDate.GetHashCode();
    }
}
Коллега, хэш вследствие своей природы (и тем более небольшого размера — всего 32 бита) в любом случае будет давать коллизии, особенно на большом количестве объектов.

Одно из требований к функции получение хеш-кода — скорость.

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

Например, пусть объект имеет 3 булевых поля.
Определенно, XOR трех таких полей будет давать коллизии, и нужно продумать функцию хеширования — например, превращать булево поле в бит, для каждого поля делать сдвиг с определенным шагом, и формировать хеш-код с помощью OR.

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

Однако, целью этой статьи было показать общие принципы сравнения объектов по значению, включая некоторые подводные камни, и сосредоточиться в большей степени на методе Equals.

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

Бесспорно, но сам факт, на мой взгляд, упомянуть все-таки стоило (ну и в идеале подобрать соответствующий пример).
Спасибо. Пожалуй, стоило чуть подробнее остановиться на вопросе коллизий хеш-кодов.
Внесу это в статью.
правильно ли это?

Это допустимо. Обычно этого стараются избежать (как раз за счет "сдвига" каждого сравниваемого свойства), но природа хэш-функций такова, что коллизии будут неизбежно.


Так что хотя это и нежелательно, это допустимо.

Все ждал когда же пойдет речь про IEquatable
Спасибо. Об этом в продолжении.

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

>private static DateTime? NormalizeDate(DateTime? date) => date?.Date;

DateTime разве nullable? Насколько я знаю она не может быть null.
Да, спасибо, перепутал со случаем, когда просто DateTime сравнивается с null.
Однако, может возникнуть ситуация, когда необходимо считать объекты некоторого класса равными, если они определенным образом совпадают по своему содержимому (тождественны).

А можете реальный пример привести? Кроме случаев типа tuple.

Tuple — это просто частный случай объекта-значения. Скажем, у меня есть некие метаданные, где ключами выступают сложно-составные значения (которые можно представлять кортежами, но представлять записями намного проще), и вот эти ключи-то и надо сравнивать по значению (и при этом сохранять нормальный ОО-доступ к каждой составной части, а иногда и методы поддерживать).

Прочему в этом случае не использовать tuple и не городить свои классы?

Во-первых, потому, что Upn и Email читаемее, чем Item1 и Item2. Во-вторых, потому, что если вам внезапно нужно недефолтное сравнение строк, вам придется не только городить свои классы, но еще и передавать их в каждую операцию.

в C# 7.0 есть встроенные туплы, где можно задавать нужные имена свойств
UFO just landed and posted this here
Любой случай, когда семантически сущность ведёт себя как значение (запись, record), но технически является объектом. За примером далеко ходить не надо, можно взять стандартный System.String.

Лично я считаю, что отличную идею разведения на два лагеря сущностей-значений/записей (которые тождественны, если тождественна их структура/содержимое) и сущностей-объектов (которые имеют identity и для которых объект тождественнен только самому себе) в дотнете не довели до ума. На struct-ы, которые как раз и должны использоваться для «записей», накладывается слишком много технических ограничений. В результате, выбирать между struct и class приходится чисто их технических соображений, и приходится брать class для реализации типов-записей. В результате чего и имеем типы со смешанным поведением, которые как бы объекты, передаются по ссылке, но сравниваются по значению. Из реальных примеров могу привести Data Transfer Object, которые не более чем записи и должны сравниваться по содержимому.

Еще раз — я конкретный пример хочу увидеть. Для string никто не будет писать такой код, он уже написан. Для tuple тоже.


Я уже более 10 лет программирую на .NET и еще ни разу не написал в реальном проекте свою реализацию Equals.


Из реальных примеров могу привести Data Transfer Object, которые не более чем записи и должны сравниваться по содержимому.
Кому должны? В каком случае DTO вообще сравнивать надо?
Еще раз — я конкретный пример хочу увидеть. Для string никто не будет писать такой код, он уже написан. Для tuple тоже.

Это интересный вопрос.

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

Уже вещественные числа (несмотря на то, что они — struct) в силу своей природы, выбиваются из ряда:
x.Equals(x) returns true, except in cases that involve floating-point types.

Реализация сравнения по значению стандартного Uri неоднозначна — сравнивается только часть свойств, и в зависимости от версии FW, набор сравниваемых свойств менялся.
А что, если нам нужно сравнить Uri иным образом? Или поместить в словарь все имеющиеся варианты записи Uri, а GetHashCode/Equals позволят поместить только часть?

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

— Сравнивать в Equals все поля. А как именно, кстати, сравнивать? — для строк-имен — с учетом регистра или без, с учетом какой культуры, учитывать ли ведущие и ведомые пробельные символы, учитывать ликоличество пробельных символов и их внутренние коды внутри имен/фамилий/отчеств, учитывать ли разницу между составными именами с дефисом, если где-то дефис, а где-то тире, и т.д.

— Использовать внутри Equals/GetHashCode только идентификатор типа СНИЛС или SSN. А точно подойдет вариант? — что если нам нужно в словарь поместить все возможные записи, и потом уже разбираться, где дубли с различающимися неключевыми полями и почему?

На мой взгляд, знать механизм сравнения объектов по значению нужно, чтобы понимать, как это работает для типов из стандартной библиотеки (включая нюансы для тех же вещественных чисел, и то, для всех ли типов сравнение реализовано корректно),
и на случай, если вы разрабатываете собственную библиотеку или фреймворк на некую тему — скорее всего, в них будут типы, переменные которых нужно будет сравнивать по значению в клиентском коде.

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

Вообще, спасибо за вопрос. Об этом стоит поговорить в продолжении.
Ответить на поставленный вопрос не так уж сложно. Всё определяется тем, является ли ваш тип объектом.
К объектам в используемой объектной среде всегда добавляется уникальный идентификатор. В C++ это указатель, в C#/Java это ссылка, в объектных БД это OID, и даже когда в реляционной БД вы используете суррогатный ключ — это тоже в каком-то смысле искуственный уникальный идентификатор. Собственно, необходимость в таком идентификаторе и есть ответ на вопрос, чем является тип с точки зрения семантики. Возьмём ваш Person: скорее всего у вас это будет тип-объект, т.к. даже если у разных Person совпадают все данные, то всегда есть вероятность, что это разные люди. Иными словами, вам нужен искуственный идентификатор, чтобы их различать. Если же в рамках вашей задачи вас не интересуют люди без российского паспорта, и вы условились различать всех по номеру паспорта, то вы вполне можете рассматривать Person как тип-значение.
А как именно, кстати, сравнивать? — для строк-имен — с учетом регистра или без, с учетом какой культуры, учитывать ли ведущие и ведомые пробельные символы, ...

Это не нужно рассматривать в рамках задачи сравнения Person. Если ФИО нужно сравнивать иначе, например без учёта регистра, нужно тогда для полей Name использовать не стандартный string, а некий NameString, который, к примеру, будет сравниваться регистронезависимо. При этом он всё равно будет оставаться типом-значением. Вопрос сравнения нельзя рассматривать независимо от типизации.
По возможности, строить архитектуру классов таким образом, чтобы эта задача не возникала.

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

И здесь опять следует разобраться, как вы понимаете ваши «записи». Если они могут иметь дубли, но вы таки хотите их различать, вы автоматически наделяете их каким-то отличительным признаком, даже если вы пока не понимаете, каким конкретно. Допустим, вы считываете записи из файла, и их содержимое действительно совпадает. Но вы тем не менее считаете эти записи разными. Как вы их различаете? Ну допустим вы можете говорить о порядковом номере записи. В таком случае именно он является идентификатором и его нужно добавить в ваши дублирующиеся «записи», чтобы перестать считать их дублирующимися. Тогда их можно спокойно добавлять в один список. Либо, вы можете объявить эти «записи» объектами, и тогда такой идентификатор даст вам ваша языковая среда. В случае C# это будет ссылка на объект. Вы также сможете положить несколько ваших дублирующихся по содержимому «записей» в один список.
Позже, когда вы решите задачу с дублирующимися записями, вы скорее всего всё-таки захотите отказаться от номера записи в качестве идентификатора и будете использовать содержимое СНИЛС для сравнения — иначе зачем вообще этот СНИЛС, если он не идентифицирует?

Вообще это больная тема для многих разработчиков и проектов, и касается она почти всех архитектурных слоёв — от пользовательского интерфейса до базы данных.
Кому должны? В каком случае DTO вообще сравнивать надо?

Ну, к примеру, определить наличие изменений в ответе сервера, если нет более удобных механизмов вроде ETag. Хочется же один раз такое сравнение написать, и пользоваться им. Логично, что в этом случае DTO должен сравниваться как запись. У него нет своего identity, это лишь слепок состояния объекта.
Кому должны?

У типа всегда есть семантика. Я думаю вы сильно удивитесь, если некая языковая среда скажет что 5 != 5 только потому, что 5 и 5 это разные «объекты». У вас есть определённые ожидания от поведения числовых литералов. Аналогичные ожидания есть и в моём примере с DTO.
Я уже более 10 лет программирую на .NET и еще ни разу не написал в реальном проекте свою реализацию Equals.

Видимо вы не пользовались NHibernate :). Он Equals любит)
Я уже более 10 лет программирую на .NET и еще ни разу не написал в реальном проекте свою реализацию Equals.

И ни одного equality comparer тоже?

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

Ну то есть вам действительно ни разу не приходилось использовать недефолтный компарер, скажем, для HashSet или Dictionary?

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

Я в этом случае tuple использую.

Для тупла придется написать собственный компарер (и не забыть передать его в каждую операцию). Написать собственный рекорд в каком-то смысле дешевле.

UFO just landed and posted this here
Структуры (struct) изначально были созданы для взаимодействия с неуправлямым API и для производительности работы с примитивными типами вроде чисел.
Если бы в .NET были бы одни только классы, то все равно пришлось бы решать проблемы различения сущностей-значений и сущностей-объектов.
Очевидно, что предлагаемый в .NET механизм, когда для этого приходится каждый раз вручную с рядом нюансов реализовывать GetHashCode/Equals и опционально Equals и операторы сравнения, неудобен и имеет большой потенциал ошибок.
Очевидно, что предлагаемый в .NET механизм, когда для этого приходится каждый раз вручную с рядом нюансов реализовывать GetHashCode/Equals и опционально Equals и операторы сравнения, неудобен и имеет большой потенциал ошибок.

Не в .net, а в C#. В том же F# берете record type, и ничего не надо реализовывать вручную. Кстати, в C# их тоже обещали, но, кажется, в 7-ом опять не дадут.

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

Или, может, F# задействует некие механизмы .NET, а C# — нет
— как это было с фильтрами исключений, когда они были на уровне IL и Execution Engine, VB поддерживал их много лет, а C# стал поддерживать только в 6.0

В .NET вообще нет четкой границы, где кончается платформа, а где начинается язык, и наоборот
Поэтому вряд ли это сделают в C# только на уровне компилятора, скорее внесут что-то в рантайм, а в FCL еще атрибуты внесут, что-то вроде EquatableProperty.
Согласен, компилятор F# генерит отличные компараторы. В своё время пару классах для задач на графах решил написать на F# в отдельной сборке, и не пожалел. Впрочем, там существует понятие структурной эквивалентности, которым в C# не пахнет, и которое не нужно было бы, если б записи изначально лучше поддерживались в C# или самой платформе.
Sign up to leave a comment.

Articles