Pull to refresh
14
0
Григорьев Антон @Vglk

User

Send message

Возможно, в ранних версиях .NET Framework такая ошибка и в самом деле была. Но даже если и так, то сейчас этой ошибки нет, сравнения выполняются правильно (в т.ч. и сравнение NaN с самим собой, которое тоже дало бы неверный результат). О том, что свои утверждения я проверял только на .NET Framework 4.8, я предупредил в начале статьи. Вы же не будете утверждать, что наличие ошибок в каких-то старых версиях делает неверными мои утверждения насчёт текущей версии, свободной от этих ошибок?

Резюмирую. На данный момент имеем следующие ваши претензии к статье:
1. Про количество десятичных знаков - выше объяснил, почему считаю свои утверждения корректными.
2. Претензию не к выводам, а к стилю изложения.
3. Претензию к тому, что я ничего не написал про ошибку, которая отсутствует в современных версиях .NET Framework.

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

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

Вы можете переадресовать свои претензии к разработчикам справки Microsoft, откуда я взял эти цифры: https://learn.microsoft.com/ru-ru/dotnet/csharp/language-reference/builtin-types/floating-point-numeric-types?devlangs=csharp&f1url=%3FappId%3DDev16IDEF1%26l%3DRU-RU%26k%3Dk(double_CSharpKeyword)%3Bk(TargetFrameworkMoniker-.NETFramework%2CVersion%253Dv4.8)%3Bk(DevLang-csharp)%26rd%3Dtrue

Говорить о точности в смысле десятичных знаков можно, надо только понимать, что скрывается за этим утверждением. Никто не говорит о том, что десятичные знаки там хранятся в каком-то виде, речь совсем о другом. Мы берём произвольное вещественное число и представляем его, например, в виде числа одинарной точности с минимально возможной погрешностью. Записываем получившееся значение как десятичное число. Так вот, точность 6-9 знаков означает, что как минимум 6 десятичных знаков у исходного и у получившегося чисел совпадают, а у большинства чисел совпадёт не больше 9 знаков ("большинство" тут, скорее всего, означает применение правила трёх сигм или аналогичного).

Тут следовало бы сказать, что за числа в первом и во втором случае. В первом - число 3583/2^15, то есть представимое точно при наличии хотя бы 15 знаков в мантиссе. Во втором случае число не является представимым точно и поэтому для мантиссы разной длины будет отсечение в разных местах с разными последствиями. 

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

Если вам такой стиль изложения кажется неправильным, то это, конечно же, ваше право. Но даже если вы в этом правы, то плохой стиль изложения - это совсем не то же самое, что ошибочные выводы, о которых вы писали.

Это я писал про компилятор из MS Visual Studio. Там есть опция /fp:fast. В справке про неё написано следующее:

Параметр /fp:fast позволяет компилятору изменять порядок, объединять или упрощать операции с плавающей запятой, чтобы оптимизировать код с плавающей запятой для скорости и пространства. Компилятор может пропускать округление при инструкциях присваивания, typecasts или вызовах функций. Он может переупорядочение операций или алгебраические преобразования, например, с помощью ассоциативных и распределительных законов. Он может изменить порядок кода, даже если такие преобразования приводят к заметно отличающимся поведением округления. Из-за этой расширенной оптимизации результат некоторых вычислений с плавающей запятой может отличаться от результатов, созданных другими /fp вариантами. Специальные значения (NaN, +infinity, -infinity, -0,0) не могут распространяться или вести себя строго в соответствии со стандартом IEEE-754. Сокращения с плавающей запятой могут создаваться в ./fp:fast Компилятор по-прежнему привязан к базовой архитектуре в /fp:fast, и с помощью параметра могут быть доступны дополнительные оптимизации /arch .

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

/fp:fast предназначен для программ, которые не требуют строгого порядка исходного кода и округления выражений с плавающей запятой и не используют стандартные правила для обработки специальных значений, таких как NaN. Если код с плавающей запятой требует сохранения порядка и округления исходного кода или использует стандартное поведение специальных значений, используйте /fp:precise. Если код обращается к среде с плавающей запятой или изменяет ее для изменения режимов округления, распаковки исключений с плавающей запятой или проверка состояния с плавающей запятой, используйте ./fp:strict

https://learn.microsoft.com/ru-ru/cpp/build/reference/fp-specify-floating-point-behavior?view=msvc-170&f1url=%3FappId%3DDev16IDEF1%26l%3DRU-RU%26k%3Dk(VC.Project.VCCLCompilerTool.floatingPointModel)%26rd%3Dtrue

Вообще, простые типы делятся на фундаментальные (base types) и обобщённые (generic types; не путать с generics - обобщениями, т.е. параметризованными типами). Фундаментальные типы - это типы, которые в любой реализации остаются одинаковыми, а обобщённые - те, которые могут меняться от реализации к реализации, чтобы наилучшим образом соответствовать конкретной среде. Так, в Паскале типы SmallInt и LongInt фундаментальные, это всегда и везде будет 16-битное и 32-битное целые соответственно. А тип Integer - обобщённый, в 16-разрядных версиях он был 16-битным, в 32-разрядных стал 32-битным. Не знаю, как с этим обстоят дела конкретно в Фортране, но общий подход сейчас такой.

С фортраном никогда не имел дела, но по моему небольшому опыту работы с интеловскими компиляторами для C++ я знаю, что они нацелены на то, чтобы выжать максимум именно из интеловских процессоров. В то время как компиляторы Microsoft стараются не использовать функции, доступные только на этих процессорах, чтобы код был более переносимый. Как пример, в C++ есть тип long double, который, согласно спецификации языка, совпадает с double или обеспечивает более высокую точность. В компиляторах Microsoft long double - это double, в компиляторах Intel это 80-битный тип, соответствующий по формату регистрам FPU.

> А как это слово инициализируется при старте программы?

У .NET-библиотек, которые отображаются в адресное пространство компилятора, есть секции инициализации, которые выполняются в момент этого отображения. В одной из них прописан перевод FPU в нужный режим.

> Разве ключи компиляции не позволяют это настроить?

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

>  Например, что произойдет, если вдруг сторонняя функция изменит предустановленные настройки уже в рантайме?

По сути, функция _controlfp, использующаяся в примерах, это и есть тот случай, когда сторонняя функция изменяет настройки. Возможно, они вернутся обратно после вызова какого-то метода .NET. По опыту работы с нативными приложениями знаю, что некоторые функции WindowsAPI (например, CreateWindow) переводят FPU в режим 53-битной мантиссы, поэтому результаты расчётов до вызова этой функции и после могут различаться.

Смотрел, просто решил, что писать ещё и об этом - совсем статью раздувать. Там, помнится, можно указывать, использовать SSE или FPU, а ещё есть опция, которая позволяет компилятору ради скорости максимально упрощать выражения. С пометкой, что такое упрощение иногда может приводить к неправильным результатам. В частности, код для расчёта машинного эпсилон с этой опцией выдаёт 0, так как компилятор решает, что в выражении 1 + d/2 > 1 слева и справа можно сократить единицу, и упрощает его до d/2 > 0, а потом решает, что при сравнении с нулём на 2 делить не обязательно, и выражение превращается в d>0. В итоге деление идёт до тех пор, пока переменная не обратится в ноль.

Какие конкретно выводы вы считаете ошибочными?

.NET Core уже давно официально переименован в просто .NET, ещё с 5-й версии. Вот пруф: https://learn.microsoft.com/ru-ru/lifecycle/products/microsoft-net-and-net-core

Я, видимо, зря не заострил внимание на этом моменте в статье. Считать таким образом десятичные цифры бессмысленно, потому что имеют значение только те цифры, которые находятся в пределах погрешности, цифры, лежащие за погрешностью, значения не имеют. Когда, например, пишут, что float имеет точность 6-9 десятичных цифр, это значит, что от 6 до 9 цифр будут точно правильными, а дальше как повезёт. Как, например, для значения 0.1 отклонение начинается с 9-й значащей цифры. Для отдельных значений, являющихся суммами степеней двойки, точность может оказаться существенно выше и даже, как вы правильно заметили, дойти до 52 цифр. Но это - выбросы в статистике, для всех остальных чисел точность будет намного меньше.

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

Вот обычная ситуация. Пусть есть класс X и у него три наследника: A, B и C. И у этих наследников в конструкторе требуется довольно сложная инициализация, практически идентичная, но отличающаяся в некоторых нюансах. Естественно, возникает желание написать код инициализации один раз в конструкторе класса X, а нюансы реализовать за счёт вызова виртуального метода, который будет переопределён в A, B и C в соответствии с их спецификой. В C# это, в принципе, работает (в отличие от C++, в котором таблица виртуальных методов инициализируется по мере вызовов унаследованных конструкторов, поэтому в момент выполнения конструктора X вызов виртуального метода приведёт к вызову метода, определённого в X, а не перекрытого наследником). Но если нужно, чтобы перекрытые методы как-то зависели от полей, объявленных в классах-наследниках, а значения этих полей — от параметров, переданных в конструкторы, то в C# не существует нормального решения этого вопроса. Присваивание этим полям значений невозможно выполнить до вызова унаследованного конструктора, а без этого вызываемые им виртуальные методы работают некорректно. Уже несколько раз с такой задачей сталкивался, и ни разу не удалось найти нормального решения.

Вариант, который предложил автор статьи, завязан на рефлексию и, соответственно, имеет целый ряд недостатков вроде потери производительности и невозможности обнаружения ошибок во время компиляции. Но это вина не автора, а разработчиков языка, которые не предусмотрели такую очень нужную возможность.
Не сработал этот способ. Статический конструктор вызывается при первом вызове какого-то метода, оператор typeof его не вызывает, поэтому оказалось нельзя получить метаинформацию о Container сразу после создания типа. Пришлось выкручиваться: из Container статический конструктор выкинул, в ContainerBase вместо свойства Meta сделал статический метод GetMeta(Type t), который проверяет наличие информации о типе t в словаре meta, и если её там нет, выполняет анализ, который раньше был в конструкторе Container, и помещает информацию в словарь. В общем, выкрутился, но сколько же лишних телодвижений из-за того, что C# не обеспечивает полиморфизм на уровне типа!
Не знаю. Особенно если учесть, что в Delphi есть ещё простые процедуры и функции, вообще не привязанные ни к какому классу.

Если я правильно помню, статические методы появились, когда Delphi пытались переделать под .NET. Видимо, для совместимости с другими .NET-языками.

От «перепутал» вообще средств нет. Надо, допустим, к числу прибавить 2, а программист промахнулся и напечатал 3. Ни один компилятор от такого не спасёт.
Виртуальными в Delphi можно делать не статические, а классовые методы. Статические методы в Delphi тоже есть (появились в поздних версиях, возможно, под влиянием C#), но их виртуальными делать, естественно, нельзя.
В общем-то, я уже отвечал на этот вопрос, опишу подробнее. Итак, типы T создаёт пользователь, в типичном случае применения библиотеки их придётся создавать 15-20 штук, а то и больше. Две проблемы:
1. В каждом типе пользователь должен не забыть описать статический член определённого формата. Заставить компилятор проверить, что пользователь не забыл и не перепутал, невозможно.
2. Библиотека не сможет использовать полиморфизм при работе с этими типами. Имея описание одного из типов T в виде Type, библиотека не доберётся до нужного статического члена без использования рефлексии.

В общем-то, оба этих недостатка присущи и моему решению с атрибутами, так что выбор между атрибутами и статикой делается из чисто эстетических соображений. К сожалению, ничего лучшего C# не предлагает.
Если говорить о моей реальной задаче, то в ней вообще нет никакого BaseT, это я его тут написал для того, чтобы было удобнее обсуждать. В моём же случае Container<T> никогда не создаёт экземпляры T и не использует созданные кем-то другим. Единственное, что ему нужно от T — прочитать его атрибуты. Поэтому T может быть классом (даже абстрактным), интерфейсом, структурой или перечислением — это ни на что не повлияет.
И пользователю библиотеки вместо одного типа придётся описывать два, да ещё и Container приобретает второй параметр, бессмысленный для пользователя, нужный только для поддержания инфраструктуры. Мне бы не понравилось пользоваться такой библиотекой. Я уж не говорю о том, что для одного T можно породить двух наследников BaseMeta и породить два разных Container с одним типом, но разной метаинформацией о нём. В каких-то случаях такая возможность, может быть, даже окажется полезной, но в моём случае может привести к неприятным ошибкам.
Для этого нужно иметь экземпляр наследника. Если он не возникает естественным образом (а в моей задаче не возникает), его приходится создавать. Это приемлемо работает только при выполнении двух условий:
1. Создание экземпляра стоит не очень дорого.
2. У создания нет побочных эффектов.

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


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

Information

Rating
Does not participate
Registered
Activity