Каким должен быть язык программирования? Анализ и критика Описание языка Компилятор
Отечественные разработки Cтатьи на компьютерные темы Компьютерный юмор Новости и прочее

Десятка худших фич C#

Вашему вниманию представляется сокращённый перевод статьи, здесь её оригинал (на английском).

Хотя C# имеет много замечательных фич, некоторые из них можно было бы разработать иначе или полностью опустить, — говорит Эрик Липперт, которому следует знать, поскольку он работал в проектном комитете. Он делится своей десяткой недостатков дизайна C#.

Наиболее частым вопросом, который мне постоянно задавали, был такой: «Есть ли какие-то решения по языковому дизайну, о которых вы теперь сожалеете?» и мой был ответ: «Да!»

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

10. Пустой оператор, который ничего не делает

Как и многие другие языки, основанные на синтаксисе C, C# требует, чтобы операторы заканчивались либо закрывающей фигурной скобкой «}», либо точкой с запятой «;». Особенность этих языков в том, что одиночная точка с запятой является вполне законной:

void M()
{
  ;  // Вполне законно
}

Зачем нам оператор, который ничего не делает? Есть несколько законных способов использования:

  • При отладке вы можете установить точку останова для пустого оператора. В Visual Studio иногда может быть непонятно, где находится программа в состоянии останова: в начале или в середине оператора. С пустым оператором всё становится однозначным.
  • Есть контексты, в которых требуется оператор, который ничего не делает:
while(whatever)
{
  while(whatever)
  {
    while(whatever)
    {
      if (whatever) // break out of two loops
        goto outerLoop;
      [...]
    }
  }
  outerLoop: ;
}
В C# нет «break с меткой», а метка требует оператора; здесь пустой оператор существует исключительно для того, чтобы быть целью метки. Конечно, если бы кто-то попросил меня просмотреть этот код, я бы сразу предположил, что глубоко вложенные циклы с ветвлением goto являются основным кандидатом для рефакторинга в более читаемую и поддерживаемую форму. Такое ветвление довольно редко встречается в современном коде.
  • В следующем примере показан вред пустого оператора:
while(whatever); // Опа! Пустой цикл, а блок «{}» циклу не принадлежит.
{
  [...]
}
Точка с запятой в первой строке почти не видна, но она сильно влияет на смысл программы. Тело этого цикла – пустой оператор. А вот за ним следует блок, который, вероятно, и должен был быть телом цикла. Этот фрагмент кода переходит в бесконечный цикл, если условие истинно, и выполняет тело цикла один раз, если условие ложно. К счастью, компилятор предупреждает об этом.

Примечание автора сайта

  • Вопросы отладки можно решить по-другому, без подстраивания языка под отладку.
  • Для выхода из вложенных циклов нужен не «break <метка>», а «break <сколько циклов покинуть>»:
(while . . .
    (while . . .
      break 2))
  • В конструкциях языка, которые начинаются и заканчиваются скобками, неочевидный конец конструкции вообще невозможен.
(while . . .)
(if . . . else . . .)
Это своеобразная «помехозащищённость»: скобки придают коду «синтаксическую устойчивость».

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

9. Слишком много равенств

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

  • Пользовательские операторы: >, <,> =, <=, == и !=
  • Переопределение метода Equals (объект)
  • Этот метод будет упаковывать структуру, поэтому вам также понадобится метод Equals (MyStruct), который можно использовать для реализации этого:
Iequatable.Equals(MyStruct)
  • Вам лучше реализовать и это:
IComparable.CompareTo(MyStruct)
  • Для получения дополнительных бонусных баллов вы можете реализовать неуниверсальный метод IComparable.CompareTo, хотя в наши дни я, вероятно, не стал бы.
Iequatable.Equals(MyStruct)

Я насчитал несколько способов, и все они должны согласовываться друг с другом; было бы странно, если бы x.Equals (y) было истинным, а x == y или x > = y — ложным. Это похоже на ошибку дизайна языка.

Разработчик должен последовательно реализовать все эти способы, но выходных данных только одного из них — универсального CompareTo — достаточно, чтобы вывести значения остальных.

Всё это излишне сложно. Должно быть примерно так: «Если вы реализуете метод CompareTo, вы получите все операторы бесплатно».

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

Примечание автора сайта

Было бы неплохо иметь при определении, допустим, «>» и «==» получать уже определённую «>=»: если A > B || A == B — из этого следует A >= B

8. Операторы сдвига

Как и во многих других языках на основе C, в C# есть операторы << и >>, которые сдвигают биты целого числа влево и вправо. У них есть ряд проблем дизайна.

Во-первых, что, как вы думаете, произойдет, если сдвинуть 32-битное целое число влево на 32 бита? Это может показаться бессмысленной операцией, и это так. Так что же она делает? Наверно, Вы подумали, что сдвиг целого числа влево на 32 бита — это то же самое, что сдвиг целого числа влево на 1 бит, а затем повторение этой операции еще 31 раз, что даст ноль.

Это вполне разумное предположение полностью ложно. Сдвиг 32-битного целого числа влево на 32 не выполняется; это то же самое, что сдвиг на ноль. Более странно: сдвиг на 33 бита — это то же самое, что сдвиг на 1. В спецификации C# сказано, что операнд счетчика сдвига обрабатывается так, как если бы пользователь написал count & 0x1f. Это немного лучше, чем C, в котором если смещение слишком велико, то это вызывает неопределенное поведение.

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

Давайте сделаем еще больший шаг назад. Почему мы рассматриваем целые числа, название которых подразумевает, что они должны рассматриваться как числовые величины, как если бы они на самом деле были небольшими массивами битов? Подавляющее большинство программистов на C# сегодня вообще не пишут битовый код; они пишут бизнес-логику, когда используют целые числа. C# мог бы создать «массив из 32 бита», который был целым числом за кулисами, и поместить операторы перестановки битов только на этот конкретный тип. Разработчики C# уже сделали нечто подобное, чтобы ограничить операции с целыми числами для целых чисел и перечислений размером, как у указателя.

Здесь есть два урока:
  • Следуйте правилу наименьшего удивления. Если фича удивляет почти всех, вероятно, это не лучший дизайн.
  • Используйте систему типов в своих интересах. Если кажется, что есть два не перекрывающихся сценария использования, например, представление «чисел» и «мешков с битами», сделайте два типа

Примечание автора сайта

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

7. Я гордый член лямбда лямбда лямбда

В C# 2.0 добавлены анонимные делегаты:

Func f = 
  delegate (int x, int y) 
  { 
    return x + y;
  };

Обратите внимание, что это довольно «тяжелый» синтаксис; для этого требуется ключевое слово delegate, список аргументов должен быть типизирован, а тело — это блок, содержащий операторы. Предполагается возвращаемый тип. В C# 3.0 для работы LINQ требовался гораздо более легкий синтаксис, в котором все типы выводятся, а тело может быть выражением, а не блоком:

Func f = (x, y) => x + y;

Думается, что все, кого это касается, согласны с тем, что наличие двух несовместимых синтаксисов для одного и того же, к сожалению, является неудачным. Однако C# застрял в этом, потому что существующий код C# 2.0 по-прежнему использует старый синтаксис.

В то время «тяжесть» синтаксиса C# 2.0 рассматривалась как преимущество. Мысль заключалась в том, что пользователи могут быть сбиты с толку этой новой фичей вложенных методов, и команда разработчиков хотела, чтобы там было четкое ключевое слово, указывающее, что вложенный метод становится делегатом. Никто не мог заглянуть в будущее, чтобы знать, что через пару лет потребуется гораздо более легкий синтаксис.

Мораль проста: вы не можете видеть будущее, и вы не можете нарушить обратную совместимость, когда попадете в будущее. Вы принимаете рациональные решения, достигающие разумных компромиссов, и всё равно ошибаетесь, когда требования неожиданно меняются. Самое сложное в разработке успешного языка — найти баланс между простотой, ясностью, общностью, гибкостью, производительностью и т. д.

6. Битовые операции нуждаются в скобках

В пункте №8 я предположил, что было бы неплохо, если бы операторы перестановки битов были изолированы от типа «целое число». Перечисления так же нуждаются в такой изоляции. В перечислениях флагов очень часто можно увидеть такой код:

if ( (flags & MyFlags.ReadOnly) == MyFlags.ReadOnly)

В современном коде мы бы использовали метод HasFlag, добавленный в версию 4 .NET Framework, но этот шаблон все еще очень часто встречается в устаревшем коде. Зачем нужны эти скобки? Потому что в C# оператор «&» имеет более низкий приоритет, чем операция сравнения. Например, эти две строки имеют одинаковое значение:

if ( flags & MyFlags.ReadOnly == MyFlags.ReadOnly)
if ( flags & ( MyFlags.ReadOnly == MyFlags.ReadOnly) )

Очевидно, это не то, что задумал разработчик, и, к счастью, это не проходит проверку типов в C#.

Оператор «&&» также имеет более низкий приоритет, чем равенство, но это хорошо. Мы хотим этого:

if ( x != null && x.Y)
эквивалентно:
if ((x != null) && x.Y)
но никак не это:
if (x != (null && x.Y))
Подведу итог:
  • «&» и «|» почти всегда используются как арифметические операторы и поэтому должны иметь более высокий приоритет, чем равенство и другие арифметические операторы.
  • «Ленивые» «&&» и «||» имеют более низкий приоритет, чем равенство. Это хорошая вещь. Для единообразия «нетерпеливые» «&» и «|» операторы тоже должны иметь более низкий приоритет, верно?
  • Согласно этому аргументу, «&&» и «&» должны иметь более высокий приоритет, чем «||» и «|», но это тоже не так.

Вывод: это беспорядок. Почему C# так делает? Потому что именно так это делает C. Почему? Я передаю вам слова покойного дизайнера C Денниса Ричи: «Оглядываясь назад, было бы лучше пойти дальше и изменить приоритет «&» на более высокий, чем «==», но казалось более безопасным просто разделить «&» и «&&». В конце концов, у нас было несколько сотен килобайт исходного кода».

Ироничное замечание Ритчи иллюстрирует урок. Чтобы избежать затрат на исправление нескольких тысяч строк кода на небольшом количестве машин, мы пришли к тому, что эта ошибка дизайна повторилась во многих языках-преемниках, на которых написано миллиарды строк кода. Если вы собираетесь внести изменение, нарушающее обратную совместимость, то чем раньше это сделать, тем лучше.

5. Сначала печатайте, вопросы задавайте потом

int x;
double M(string y) { ... }
Сравните это с Visual Basic:
Dim x As Integer
Function M(Y As String) As Double
или TypeScript:
var x : number;
function m(y : string) : number

Хорошо, dim — это немного странно в VB, но эти и многие другие языки следуют очень разумному шаблону «имя переменной» — «тип переменной». Напротив, такие языки, как C, C# и Java, определяют вид объекта из контекста и последовательно помещают тип перед именем, как будто тип является самым важным.

Почему одно лучше другого? Подумайте, как выглядит лямбда:

x => f(x)

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

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

Урок: при разработке нового языка не следует рабски следовать странным соглашениям языков-предшественников. В C# можно было бы разместить аннотации типов справа, оставаясь при этом полностью понятными для разработчиков, имеющих опыт работы с C. Такие языки, как TypeScript, Scala и многие другие, сделали именно это.

Примечание автора сайта

На самом деле вопрос достаточно дискуссионный. Если не сказать спорный. Необходимо учитывать, какое направление «движения информации» принято в языке в качестве стандарта — слева направо или справа налево. Допустим, у нас есть операции «<=» и «=>»:

а <= 6;		// информация движется слева направо: а присвоено значение 6
9 => b;		// информация движется справа налево: b присвоено значение 9
А теперь определим функции:
int F1 (int a)	// информация движется слева направо, возвращаемый тип — слева
F2 (b: int):int	// информация движется справа налево, возвращаемый тип — справа
А теперь применение этих функций:
a <= F1 (6)	// информация движется слева направо: а присвоено значение F1(6)
F2(9) => b	// информация движется справа налево: b присвоено значение F2(9)
В итоге мы должны остановиться либо на стандарте «справа налево»:
а <= 6;		// информация движется слева направо: а присвоено значение 6
int F1 (int a)	// информация движется слева направо, возвращаемый тип — слева
a <= F1 (6)	// информация движется слева направо: а присвоено значение F1(6)
либо «слева направо»:
9 => b;		// информация движется справа налево: b присвоено значение 9
F2 (b: int):int	// информация движется справа налево, возвращаемый тип — справа
F2(9) => b	// информация движется справа налево: b присвоено значение F2(9)

Вывод такой: если тип ставится справа, то извольте ставить lvalue справа, а rvalue — слева. А если тип слева, то lvalue слева, а rvalue — справа. Мне кажется, Эрик Липперт не слишком глубоко вник в существо вопроса.

4. Отметьте меня

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

enum Size { Small = 0, Medium = 1, Large = 2 }
Вы можете присвоить любое значение, которое вам нравится:
Size size = (Size) 123;

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

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

[Flags] enum Permissions
{ None = 0, Read = 1, Write = 2, Delete = 4 }

Их можно комбинировать с поразрядными операторами для создания комбинаций типа «читать или писать, но не удалять». Это будет значение 3, которое не является одним из доступных вариантов. С десятками флагов перечисление всех допустимых комбинаций было бы обременительным.

Как обсуждалось ранее, проблема в том, что мы объединили две концепции в одну — выбор из набора дискретных опций и массива битов. Было бы концептуально лучше иметь два типа перечислений: один с операторами для набора различных параметров, а другой с операторами для набора именованных флагов. Первые могут иметь механизмы для проверки диапазона, а вторые могут иметь эффективные побитовые операции. Слияние оставляет нас в худшем из обоих миров.

Этот урок перекликается с уроком № 8:
  • Тот факт, что значения могут быть вне диапазона перечисления, нарушает принцип наименьшего удивления.
  • Если два варианта использования почти не пересекаются, не объединяйте их в одну концепцию в системе типов.

3. Я ставлю плюс-плюс на минус-минус

Мы снова возвращаемся к фичам C#, потому что они принадлежат C, а не потому, что это хорошие идеи. Операторы инкремента и декремента находятся в ужасном положении из-за того, что они широко используются, часто неправильно понимаются и почти никогда не нужны.

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

x++;
что можно записать так:
x += 1;
или вот так:
x = x + 1;

Далее, почти никто не может дать вам точное описание разницы между префиксной и постфиксной формами операторов. Наиболее частое неправильное описание, которое я слышу, звучит так: «Префиксная форма выполняет приращение, присваивается хранилищу, а затем возвращает значение; постфиксная форма производит значение, а затем выполняет приращение и присваивание позже». Почему это описание неверно? Потому что он подразумевает порядок событий во времени, что совсем не то, что на самом деле делает C#. Фактическое поведение такое:

  • Оба оператора определяют значение переменной.
  • Оба оператора определяют, какое значение будет возвращено хранилищу.
  • Оба оператора присваивают хранилищу новое значение.
  • Постфиксный оператор возвращает исходное значение, а префиксный оператор — присвоенное значение.

Утверждать, что постфиксная форма возвращает исходное значение, а затем выполняет приращение и присваивание, просто неверно. (Это возможно в C и C++, но не в C#.) Присваивание должно быть выполнено до того, как значение выражения будет предоставлено в C#.

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

Что мне хуже в этих операторах, так это то, что я не могу вспомнить, какой оператор точно описывает "x ++":

  • Оператор идет после операнда, поэтому результатом является значение, которое он имеет после приращения.
  • Операнд стоит перед оператором, поэтому результатом является значение, которое он имел до приращения.
  • Обе мнемоники имеют смысл — и они противоречат друг другу.

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

Наконец, многие люди, имеющие опыт работы с C++, совершенно удивлены, обнаружив, что способ, которым C# обрабатывает определяемые пользователем операторы увеличения и уменьшения, полностью отличается от того, как это делает C++. Точнее, они совсем не удивлены — они просто неправильно пишут операторы на C#, не осознавая разницы. В C# определяемые пользователем операторы инкремента и декремента возвращают значение, которое должно быть присвоено; они не изменяют хранилище.

Урок: новый язык не должен включать фичу только потому, что она традиционна. Многие языки прекрасно обходятся без неё, а в C# уже есть множество способов увеличения переменной.

Дополнительная специальная бонусная тирада!

Я чувствую то же самое в отношении использования оператора присваивания как для его значения, так и для побочного эффекта:

M(x = N());

Это означает: «Вызвать N, присвоить значение x, а затем использовать присвоенное значение в качестве аргумента для M». Оператор присваивания используется здесь из-за его эффекта, а также производимого значения, что сбивает с толку.

C# можно было спроектировать так, чтобы присваивание было законным только в контексте оператора, а не в контексте выражения.

2. Я хочу уничтожить финализаторы

Финализатор (также известный как деструктор) в C# имеет тот же синтаксис, что и деструктор в C++, но совсем другую семантику. В мае 2015 года я написал пару статей об опасностях финализаторов, поэтому не буду здесь все резюмировать. Вкратце, в C++ деструкторы выполняются детерминированно, выполняются в текущем потоке и никогда не выполняются на частично построенных объектах. В C# финализатор, возможно, никогда не запустится, если сборщик мусора решит, что он может работать, в отдельном потоке и для любого созданного объекта, даже если конструктор не завершился нормально из-за исключения. Эти различия затрудняют создание действительно надежного финализатора.

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

Эта фича сбивает с толку, подвержена ошибкам и часто неправильно понимается. Она имеет синтаксис, очень знакомый пользователям C++, но совершенно другую семантику. И в большинстве случаев использование этой фичи опасно, ненужно или является симптомом ошибки.

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

Урок: Иногда вам нужно реализовать фичи, которые предназначены только для экспертов, создающих инфраструктуру; они должны быть четко обозначены как опасные — не слишком похожие на фичи из других языков.

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

Предположим, у нас есть базовый класс Animal с производными типами Goldfish и Tiger:

Animal[] animals = new Goldfish[10];
animals[0] = new Tiger();

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

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

Ясно, что это ошибка опасна. Зная об этом, мы можем её избежать. Но опасность — не единственный недостаток этой фичи. Подумайте о том, как исключение во время выполнения должно быть сгенерировано в приведенной выше программе. Каждый раз, когда вы записываете выражение одного ссылочного типа в массив другого, среда выполнения должна выполнять проверку типа, чтобы убедиться, что массив действительно не относится к несовместимому типу элемента! Почти каждая запись массива становится немного медленнее, чтобы немного быстрее вызвать метод, который принимает массив базового типа.

Команда разработчиков C# добавила в версию 4.0 ковариацию по типу, так что массив золотых рыбок можно безопасно преобразовать в IEnumerable . Поскольку интерфейс последовательности не предоставляет механизма для записи в базовый массив, это безопасно. Метод, который предназначен только для чтения из коллекции, может принимать последовательность, а не массив.

C# 1.0 имеет небезопасную ковариацию массива не потому, что разработчики C# считали этот сценарий особенно привлекательным, а потому, что среда CLR имеет эту функцию в своей системе типов, поэтому C# получает ее «бесплатно». В CLR это есть потому, что в Java есть эта функция; команда CLR хотела спроектировать среду выполнения, которая могла бы эффективно реализовать Java, если в этом возникнет необходимость. Я не знаю, почему он есть в Java.

Отсюда вытекают три урока:
  • То, что это бесплатно, не означает, что это хорошая идея.
  • Если бы разработчики C# 1.0 знали, что C# 4.0 будет иметь безопасную универсальную ковариацию для типов интерфейсов, у них был бы аргумент против небезопасной ковариации массива. Но они, конечно, этого не знали (проектировать для будущего сложно).
  • По словам Бенджамина Франклина, разработчики языков, которые отказываются от небольшой безопасности типов в пользу небольшой производительности, обнаруживают, что у них нет ни того, ни другого.

У столба позора

Некоторые сомнительные фичи не попали в мой список 10 лучших:
  • Цикл for имеет странный синтаксис и некоторые очень редко используемые функции, почти полностью не нужен в современном коде, но по-прежнему популярен.
  • Использование оператора += для делегатов и событий всегда казалось мне странным. Он также плохо работает с общей ковариацией для делегатов.
  • Двоеточие «:» означает как «Расширяет этот базовый класс», так и «Реализует этот интерфейс» в объявлении класса. Это сбивает с толку как читателя, так и автора компилятора. Visual Basic объясняет это очень четко.
  • Правила разрешения имен после вышеупомянутого двоеточия недостаточно обоснованы; вы можете оказаться в ситуациях, когда вам нужно знать, что такое базовый класс, чтобы определить, что это за базовый класс.
  • Тип void не имеет значений и не может использоваться в любом контексте, требующем типа, кроме возвращаемого типа или типа указателя. Кажется странным, что мы вообще думаем о нем как о типе.
  • Статические классы – это то, как C# делает модули. Почему бы не назвать их «модулями»?
  • Никто бы не плакал, если бы унарный плюс завтра исчез.

Подводя итоги

Более половины этих неприятностей из приведённой десятки являются прямым результатом включения фич, знакомых пользователям других языков. Общий урок заключается в том, что долгая история и знакомство не являются достаточными причинами для копирования сомнительной фичи. Обдумывая, что нужно включить в язык, мы должны задавать такие вопросы:

Если фича полезна, возможен ли лучший синтаксис? Разработчики обычно умны и гибки; они обычно могут быстро изучить новый синтаксис.

Каковы фактические варианты использования в современных программах? Что мы можем разработать, напрямую нацеленное на эти случаи?

Если фича представляет собой «острый инструмент», как мы можем ограничить ее опасность для неосторожных разработчиков?

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

Опубликовано: 2022.05.12, последняя правка: 2022.05.26    20:08

ОценитеОценки посетителей
   █████████████████████████████████ 7 (77.7%)
   █████ 1 (11.1%)
   ▌ 0
   █████ 1 (11.1%)

Отзывы

✅  2022/05/19 23:49, Неслучайный читатель          #0 

мы должны остановиться ... на … стандарте «слева направо»:

F2 (b: int):int // информация движется справа налево, возвращаемый тип — справа
F2(9) => b // информация движется справа налево: b присвоено значение F2(9)
Если быть до конца последовательным в том, чтобы информация двигалась слева направо, то вот код, к которому придраться невозможно. В нём всё слева направо!
(b: int) F2:int // объявление функции
(9) F2 => b // её использование
Идеальное движение слева направо! :)

✅  2022/05/20 06:49, MihalNik          #1 

Необходимо учитывать, какое направление «движения информации» принято в языке в качестве стандарта

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

✅  2022/05/20 13:57, Автор сайта          #2 

Неслучайный читатель
В логике Вам не откажешь :) Ещё надо заметить, что Си-шная форма записи
int F1 (int a)
логичнее алголовской
F2 (b: int):int
в том, что аргументы — по одну сторону от имени функции, а результат — по другую. Ваша
(b: int) F2:int
форма лишена алголовского недостатка :)
MihalNik

такого мифического стандарта для популярных ЯП просто нет

Так это плохо, что его нет. Единообразная форма записи позволяла бы меньше помнить и больше полагаться на логику. Вот, к примеру, Си-шные функции:
  strcpy ( строка_куда, строка_откуда);         // справа <-- налево
ltoa ( число_откуда, строка_куда); // слева --> направо
fputc( символ_откуда, файл_куда); // слева --> направо
fgets( строка_куда, сколько, файл_откуда); // справа <-- налево
Почему такая анархия? Тут нет никакой логики! Из-за этого порядок следования аргументов надо обязательно помнить.

Европейские разработчики Алгола в своё время планировали сделать оператор присваивания таким:
источник -> приёмник
То есть информация по их задумке должна была двигаться слева направо. Но по настоянию американских коллег они поменяли направление движения информации, присвоение стало таким:
приёмник := источник
Но выборочно поменяв логику построения языка в одном месте и не поменяв в остальных, получаем противоречивый синтаксис.

✅  2022/05/20 14:09, Gudleifr          #3 

Ещё надо заметить, что Си-шная форма записи
int F1 (int a) ...

Пардон, Си-шная форма записи, это

F(a) ...

Си, изначально — это язык системного программирования. Он не требовал ввода информации, не нужной компилятору.

✅  2022/05/20 14:53, Автор сайта          #4 

Изначально функции можно было определять так:
F(a)		/* тип аргумента не определён, будет определён позже */
{ int a; /* а вот теперь тип определён в теле функции */ . . .}
Но потом это было признано устаревшим и нерекомендуемым к применению. И в учебнике Кернигана и Ритчи (а Ритчи, к слову, один из авторов языка) функции определяются современным способом.7

✅  2022/05/20 15:10, Gudleifr          #5 

Изначально функции можно было определять так:

Не так, а так:
F(a) int  a; { ...
Что часто было гораздо удобнее. Я до сих пор так пишу (gcc позволяет).

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

✅  2022/05/20 15:30, Автор сайта          #6 

Не так, а так

Я в таким стиле никогда не писал. Поэтому возражать даже не собираюсь. С другой стороны, как тогда описать указатель на такую функцию? Всё равно придётся записывать определение «как положено».

int (соответствующий размер ячейки памяти) можно было опускать.

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

✅  2022/05/20 15:55, Gudleifr          #7 

как тогда описать указатель на такую функцию? Всё равно придётся записывать определение «как положено»

Не "как положено", а "случайно так получилось". Чтобы отличить описание функции от вызова. На самом деле, Си-компилятору было плевать, возвращает ли функция int или вообще ничего не возвращает, т.к. значение возвращалось в регистре. И, конечно, при описании указателя на функцию (или её объявлении) не надо было указывать тип и количества параметров. Как и не требовалось, чтобы фактические параметры соответствовали списку в описании функции. И, да, это удобно.

Да, когда-то такие языки были не редкость

Опять же, дело вкуса. Кому "такие", а кому "единственно годные".

✅  2022/05/20 16:05, MihalNik          #8 

Из-за этого порядок следования аргументов надо обязательно помнить

Порядок следования аргументов в общем случае так и так придется помнить либо использовать более длинную конструкцию/имя, а не минималистичное разделение запятыми. Назвали же strcpy, а не putS2inS1 и fputc вместо putC1inF2. Кто в этом виноват? Не нравится — переделывайте макросами или транспилятором. Т.е. проблема принципиально решаема.

Европейские разработчики Алгола в своё время планировали сделать оператор присваивания таким:

источник -> приёмник

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

А кто-то из них писал про "направление движении информации"?

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

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

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

✅  2022/05/20 22:20, Автор сайта          #9 

при описании указателя на функцию (или её объявлении) не надо было указывать тип и количества параметров. Как и не требовалось, чтобы фактические параметры соответствовали списку в описании функции.

Процитирую:

В языке Си тип «указатель на функцию» весьма громоздок и неудобен. Его синтаксис далёк от краткости и для упрощения пользования такими указателями рекомендуется использовать typedef:

typedef <возвращаемый тип> (*<синоним типа>)(<параметры>);
Без этого костыля было бы трудно. Но почему так громоздко?

Дело в том, что знания одного только адреса функции недостаточно, чтобы её вызвать. Перед вызовом необходимо передать ей все её параметры. Способов передачи много, основные способы описаны в соглашении о вызовах. Важны как порядок помещения в стек или регистры параметров, так и сами параметры и их типы. Есть как прямой порядок помещения параметров (слева направо, от первого до последнего), так и обратный (справа налево, от последнего к первому, например, функции с переменным числом параметров). А ещё параметры могут передаваться через регистры, а так же статические области памяти. От соглашения так же зависит, кто восстанавливает прежнее состояние стека — вызывающая или вызываемая программа. Так же нужно знать способ возврата результата и его тип. То есть информация о функции должна быть исчерпывающей, иначе вызов функции невозможен, как невозможен вызов абонента при неполном знании его телефонного номера.

дело вкуса. Кому "такие", а кому "единственно годные".

Полагаю, что Ваша стихия — это «бои без правил»: без типизации, без предохранителей, без техники безопасности. Что ж, понимаю, сам программировал на ассемблере и вижу, что в этом есть определённое очарование. Но надо быть реалистом: если надо написать ПО для атомной станции, то выберут язык программирования с предохранителями, потому что предпочтут положиться на надёжные технологии, а не на профессионализм канатоходца на ассемблере/Форте.

А кто-то из них писал про "направление движении информации"?

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

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

Что-то я не уловил хода Вашей мысли.

✅  2022/05/20 23:29, Gudleifr          #10 

Без этого костыля было бы трудно. Но почему так громоздко?

Это издержки поздних стандартизаций. Изначально было проще.
#include<stdio.h>
#include<stdlib.h>

w1(d1)
{
printf("Helo, int %d\n", d1);
}

long w2(d1, d2)
{
printf("Helo, long %d\n", d1);
return(0);
}

double w3(d1, d2, d3)
{
printf("Helo, double %d\n", d1);
return(0.);
}

w4()
{
exit(1);
}

int ReadCode[4];

Fill()
{
ReadCode[0]=(int)w1;
ReadCode[1]=(int)w2;
ReadCode[2]=(int)w3;
ReadCode[3]=(int)w4;
}

int pc;

Step()
{
int(*fword)();
fword = (int(*)())ReadCode[pc];
pc++;
fword(1, 2);
}

main()
{
Fill();
pc=0;
while(1) Step();
}

Но надо быть реалистом: если надо написать ПО для атомной станции, то выберут язык программирования с предохранителями, потому что предпочтут положиться на надёжные технологии

Надежная технология — это язык без предохранителей. И программист, понимающий, что "типизация, предохранители и техника безопасности" — это заведомый источник ошибок.

✅  2022/05/22 00:00, alextretyak          #11 

Было бы неплохо иметь при определении, допустим, «>» и «==» получать уже определённую «>=»

Если определён оператор «>», то операторы «<», «>=», «<=» и даже «==» можно получить из него:
fn `<`(T a, T b) {return b > a}
fn `>=`(T a, T b) {return !(a < b)}
fn `<=`(T a, T b) {return !(b < a)}
fn `!=`(T a, T b) {return a < b or b < a} // равнозначно return a < b or a > b
fn `==`(T a, T b) {return !(a != b)}

✅  2022/05/23 22:02, Автор сайта          #12 

Gudleifr

Это издержки поздних стандартизаций. Изначально было проще.

Моим первым учебником Си была книга Кернигана и Ритчи, там уже было движение в сторону более строгой типизации. А Ваш код выглядит, как написанный на родоначальнике — языке BCPL. Немного переделал его. Неправда ли логично, что если функция описана как функция от трёх аргументов, то внутри неё логично использовать все три параметра? Вот что получилось:
#include<stdio.h>
#include<stdlib.h>

w1(d1){
printf("Hello, int: %d\n", d1);
return(d1);
}

long w2(d1, d2) {
printf("Hello, long: %d\n", (long) d1 + d2);
return((long) d1 + d2);
}

double w3(d1, d2, d3) {
printf("Hello, double %f\n", (double) d1 + d2 + d3);
return((double) d1 + d2 + d3);
}

w4() {
exit(1);
}

int ReadCode[4];

Fill() {
ReadCode[0]=(int)w1;
ReadCode[1]=(int)w2;
ReadCode[2]=(int)w3;
ReadCode[3]=(int)w4;
}

int pc;

Step() {
int(*fword)();
fword = (int(*)())ReadCode[pc];
pc++;
fword(1, 2);
}

main() {
Fill();
pc=0;
while(1) Step();
}
Этот код выводит чушь, в моём случае такую:
Hello, int: 1
Hello, long: 3
Hello, double 4198489.000000
Это потому что сняли предохранители. А с ними бы ошибки были видны уже на этапе компиляции. Наверное, для кого-то найти ошибку через отладку — это азартно. А если нашёл её компилятор, то это слишком скучно и академично — никакого творчества.

alextretyak

То, что Вы написали — это замечательно. Правда, я хотел сказать о другом. О том, что после описания «>» (если опираться на Ваш пример), неплохо бы иметь уже описанными остальные операторы: «<», «>=», «<=», «!=», «==» — без необходимости их описания. Например:
fn `>`(T a, T b) {. . .}	// Описание «>» (в Вашей нотации)
// После этого «<», «>=», «<=», «!=», «==» являются описанными
if (a == b) // Применяем «==», которую не пришлось описывать
Хотя этот код не будет максимально эффективным. Например, оператор «==» разворачивается в
!(a != b)
который, в свою очередь разворачивается в
!(a < b or a > b)
То, что Вы написали, просто замечательно для математиков. А системный программист увидет в этом изъяны :)

✅  2022/05/23 22:09, Gudleifr          #13 

Этот код выводит чушь, в моём случае такую

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

✅  2022/05/23 22:12, Gudleifr          #14 

P.S. А K&R, все-таки, лучше брать 1-е издание. До начала этого бреда со стандартизацией (т.е. переориентированием на "метод масштабирования").

✅  2022/05/24 08:46, MihalNik          #15 

Хотя этот код не будет максимально эффективным.

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

✅  2022/05/24 08:54, Gudleifr          #16 

Т.е. требуется в дополнение к языку описания данных добавить язык описания алгебр над ними?

✅  2022/05/24 12:02, Автор сайта          #17 

Gudleifr
В философии языка, которая формулирует цели и задачи, записано:

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

Поэтому если ошибку возможно найти на этапе компиляции, то за это стоит побороться. Ценой такого решения являются некоторые ограничения, желательно необременительные. Но ведь ограничения в одном месте дают выигрыш в другом! Например, если в языке есть чистые функции или свойство чистоты функции можно вывести, то для языка не надо придумывать отдельный язык макросов. А уникальность имён функций даёт возможность упростить получение информации о типе указателя на функцию.

О Хаскелле, в котором много ограничений, в котором статическая типизация, пишут, что он увеличивает скорость разработки в 5 раз. Или пишут:

Чтобы первый элементарный проект хотя бы просто скомпилировался, ушло почти два месяца курения учебников, мануалов и туториалов по вечерам. Правда скомпилировавшись, проект сразу заработал и фигачил под полной нагрузкой (6k rps с пиками до 15) полгода, без изменений вообще.

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

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

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

K&R, все-таки, лучше брать 1-е издание.

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

✅  2022/05/24 12:26, Gudleifr          #18 

лучше... желательно необременительные... можно сделать достаточно много...

Надеюсь, Вы понимаете, что все это качественные оценки, не имеющие практического подтверждения?

Наверное, потому, что там осталось больше черт BCPL

Нет. Я уже Вам писал:

...появился C для затыкания дыр в UNIX

И поэтому большинство принятых там решений завязаны на свойства UNIX или даже машины, на которой все это делалось:
- последовательные символьные файлы;
- встроенные библиотеки;
- управляющая память;
- страничная организация оной.
И C интересен, в первую очередь, решениями подгонки языка под железо. Именно эту методологию подгонки и надо было стандартизировать, а не закреплять аппаратно-зависимые решения в отрыве от железа. С был идеален для программирования быдло-методом и годен для остальных. ANSI C — только для программирования масштабированием.

✅  2022/05/24 16:42, Gudleifr          #19 

Ну так мы с Вами свободные люди и свободны в выборе как любимого языка, так и людей, с которыми хочется общаться на темы языков программирования

Ладно, понял, дальше без меня.

✅  2022/05/24 21:59, Неслучайный читатель          #20 

Но по настоянию американских коллег они поменяли направление движения информации, присвоение стало таким:

приёмник := источник
Догадайтесь с первого раза: почему? А чтоб было, как в Фортране!

Читаю Макса Шлее, «Qt 4.5», стр. 45:

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

Это говорит о том, что строгая статическая типизация, проверка типов и обнаружение ошибок во время компиляции — это совсем не про Qt. Далее, на стр. 52:

При соединении сигналов со слотами, передающими значения, важно следить за совпадением их типов. Например, сигнал, передающий в параметре значение int, не должен соединяться со слотом, принимающим Qstring

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

✅  2024/09/29 13:36, Автор сайта          #21 

Долго думал, в какую статью уместно поместить комментарий. Решил, что сюда, хотя речь пойдёт не о C#. Если убрать из названия статьи название языка, то о «плохих фичах» лучше писать здесь.

В статье Д.Ю. Караваева (https://habr.com/ru/articles/674640/) встретил пример, когда вместо правильного возврата значения функцией f
if x>0 then return(1); else return(-1); end f;
можно допустить ошибку (забыть о случае, когда x равен 0) и написать
if x>0 then return(1); if x<0 then return(-1); end f;
Тогда функция при х равном 0 не возвращает ничего. Для борьбы с такими ошибками компилятор PL/1 использует «мертвый код».

Но это как тот случай, когда строгая типизация обязывает функцию возвратить результат задекларированного типа. В противном случае это является синтаксической ошибкой. Но в PL/1 строгой типизации нет. И эта «фича» поворачивается к нам своей нехорошей стороной.

✅  2024/09/29 17:54, Вежливый Лис          #22 

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

✅  2024/09/29 23:40, Автор сайта          #23 

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

Добавить свой отзыв

Написать автору можно на электронную почту
mail(аt)compiler.su

Авторизация

Регистрация

Выслать пароль

Карта сайта


Содержание

Каким должен быть язык программирования?

Анализ и критика

Описание языка

Компилятор

Отечественные разработки

Cтатьи на компьютерные темы

●  О превращении кибернетики в шаманство

●  Про лебедей, раков и щук

●  О замысле и воплощении

●  О русском ассемблере

●  Арифметика синтаксиса-3

●  Концепция владения в Rust на примерах

●●  Концепция владения в Rust на примерах, часть 2

●●  Концепция владения в Rust на примерах, часть 3

●  Суть побочных эффектов в чисто функциональных языках

●  О неулучшаемой архитектуре процессоров

●  Двадцать тысяч строк кода, которые потрясут мир?

●  Почему владение/заимствование в Rust такое сложное?

●  Масштабируемые архитектуры программ

●  О создании языков

●●  Джоэл Спольски о функциональном программировании

●  Почему Хаскелл так мало используется в отрасли?

●  Программирование исчезнет. Будет дрессировка нейронных сетей

●  О глупости «программирования на естественном языке»

●  Десятка худших фич C#

●  Бесплатный софт в мышеловке

●  Исповедь правового нигилиста

●  ЕС ЭВМ — это измена, трусость и обман?

●  Русской операционной системой должна стать ReactOS

●  Почему обречён язык Форт

●  Программирование без программистов — это медицина без врачей

●  Электроника без электронщиков

●  Программисты-профессионалы и программирующие инженеры

●  Статьи Дмитрия Караваева

●●  Идеальный транслятор

●●  В защиту PL/1

●●  К вопросу о совершенствовании языка программирования

●●  Опыт самостоятельного развития средства программирования в РКК «Энергия»

●●  О реализации метода оптимизации при компиляции

●●  О реализации метода распределения регистров при компиляции

●●  О распределении памяти при выполнении теста Кнута

●●  Опыты со стеком или «чемпионат по выполнению теста Кнута»

●●  О размещении переменных в стеке

●●  Сколько проходов должно быть у транслятора?

●●  Чтение лексем

●●  Экстракоды при синтезе программ

●●  Об исключенных командах или за что «списали» инструкцию INTO?

●●  Типы в инженерных задачах

●●  Непрерывное компилирование

●●  Об одной реализации специализированных операторов ввода-вывода

●●  Особенности реализации структурной обработки исключений в Win64

●●  О русском языке в программировании

●●  Формула расчета точности для умножения

●●  Права доступа к переменным

●●  Заметки о выходе из функции без значения и зеркальности get и put

●●  Модификация исполняемого кода как способ реализации массивов с изменяемыми границами

●●  Ошибка при отсутствии выполняемых действий

●●  О PL/1 и почему в нём не зарезервированы ключевые слова

●●  Не поминайте всуе PL/1

●●  Скорость в попугаях

●●  Крах операции «Инкогнито»

●●  Предопределённый результат

●●  Поддержка профилирования кода программы на низком уровне

●●  К вопросу о парадигмах

●  Следующие 7000 языков программирования

●●  Что нового с 1966 года?

●●  Наблюдаемая эволюция языка программирования

●●  Ряд важных языков в 2017 году

●●  Слоны в комнате

●●  Следующие 7000 языков программирования: заключение

Компьютерный юмор

Новости и прочее




Последние отзывы

2024/11/01 12:11 ••• ИванАс
Русской операционной системой должна стать ReactOS

2024/11/01 00:00 ••• alextretyak
Энтузиасты-разработчики компиляторов и их проекты

2024/10/28 00:00 ••• alextretyak
Продолжение цикла и выход из него

2024/10/27 21:54 ••• ИванАс
Новости и прочее

2024/10/27 14:01 ••• Автор сайта
О русском ассемблере

2024/10/19 23:12 ••• Автор сайта
Русский язык и программирование

2024/10/25 01:34 ••• Владимир
Оценка надёжности функции с несколькими реализациями

2024/09/29 23:40 ••• Автор сайта
Десятка худших фич C#

2024/09/29 13:10 ••• Автор сайта
ЕС ЭВМ — это измена, трусость и обман?

2024/09/22 21:08 ••• Вежливый Лис
Бесплатный софт в мышеловке

2024/09/05 17:44 ••• Автор сайта
Правила языка: алфавит

2024/09/04 00:00 ••• alextretyak
Циклы

2024/09/02 22:24 ••• Автор сайта
Постфиксные инкремент и декремент