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


Комментарии:

2019-02-14 Автор сайта

Зеркальность, одинаковость подходов дают возможность упирать на логику, а не рассчитывать на память. Почему бы, к примеру, не прийти к единому правилу, как должны располагаться играющие разные роли аргументы функций? Допустим, справа — источники информации, слева — приёмники. Или наоборот, но главное — чтобы везде одинаково. Это убережёт от множества ошибок. Зачем тут разнобой?
fputc( символ_откуда, файл_куда);             // --> слева направо
fgets( строка_куда, сколько, файл_откуда);    // <-- справа налево
(См. «Слева направо или справа налево?»). Тема зеркальности правильная: не столь важно, какое движение, правостороннее или левостороннее, главное, что имеет место быть только одно из них.

О возврате и невозврате значений. Когда в языке есть синтаксически обозначенные 1) чистые функции, 2) недетеминированные без побочных эффектов, 3) с побочными эффектами, 4) недетеминированные с побочными эффектами, то это помогает разобраться с возвратом значений.

Если у нас функция, возвращающее значение, имеет 1-й или 2-й тип, то в них и слово «return» не нужно. Возвращаемым значением является последнее выражение:
    . . .   // тело функции 1-го или 2-го типа
    0)      // последняя строка функции, здесь подразумевается return 0
Если у нас процедура, то интересные моменты тоже возможны. Процедура — это подпрограмма, не возвращающая значений, смысл её существования — в производстве побочного эффекта.

Допустим, f(x) — функция производящая побочный эффект, а g(y) и h(z) — его не производящие.
    . . .   // тело процедуры (3-й или 4-й тип)
    f(x)    // производим побочный эффект
    g(y)    // не производим побочный эффект
    h(z)    // не производим побочный эффект
    return)
Вызов функций без побочного эффекта между f(x) и «return» (а это функции g(y) и h(z)) не имеет никакого смысла. Наработанные этими функциями результаты не используются далее для производства побочного эффекта. Т.е. это либо ошибка, либо выдача предупреждения о «лишнем коде», либо молчаливая оптимизация, этот код просто игнорирующая.

2023-09-20 alextretyak

Возник вопрос: кто виноват в потере зеркальности?
Ну в C++ тоже зеркальности нет:
std::cout << 12 << 34; // выведет 1234
std::string s = "a b";
std::cout << s; // выведет `a b`, но
std::cin  >> s; // если ввести `a b`, то в `s` запишется только `a`
std::getline(std::cin, s); // эта операция зеркальна `std::cout << s << '\n';`
                           // (но только при условии, что `s` не содержит символа '\n')
Не вижу в этом ничего страшного. Главное понимать, как работают операции ввода/вывода в используемом языке программирования, и применять их соответственно.
Некое подобие зеркальности есть в Python: print(s) зеркальна s = input(). Но опять же, если s не содержит символа перевода строки.

одинаковость подходов дают возможность упирать на логику, а не рассчитывать на память. ... Зачем тут разнобой?
Ну, определённая логика тут тоже есть: файловый указатель всегда передаётся последним аргументом.
А в функциях «fread» и «fwrite» вообще одинаковое количество аргументов, и то, что эти аргументы расположены в одном порядке, порой оказывается довольно удобно: код для сохранения данных в файл, состоящий из вызовов «fwrite», можно скопировать, а затем заменить «fwrite» на «fread», и получится код для чтения из файла. Этот код, конечно, потребует доработки, но переставлять аргумент FILE* по крайней мере будет не нужно.

Когда в языке есть синтаксически обозначенные 1) чистые функции, 2) недетеминированные без побочных эффектов, 3) с побочными эффектами, 4) недетеминированные с побочными эффектами
Я правильно понимаю: вы считаете, что при объявлении каждой новой функции программист должен явно обозначать, к какой категории/типу относится эта функция?

Если у нас функция, возвращающее значение, имеет 1-й или 2-й тип, то в них и слово «return» не нужно. Возвращаемым значением является последнее выражение
С тем, что слово «return» не нужно в функциях без побочных эффектов, согласиться можно. Вот только не будет ли запутывать тот факт, что синтаксическая запись возврата значения функцией будет отличаться в зависимости от типа этой функции [для функций 1-го и 2-го типа — без «return», а для 3-го и 4-го — с «return»]?
Я считаю, что разработчикам языка программирования следует выбрать какой-то один правильный вариант, а не обязывать программиста думать каждый раз нужен тут «return» для возврата значения или не нужен.

Слово «return», пожалуй, действительно не нужно в функциональных языках.
Но в императивных языках есть некоторые функции/методы, которые возвращают значение, которое может быть проигнорировано. Например, в Python есть метод pop(i) у массивов, который удаляет i-й элемент и возвращает его. Но зачастую требуется только удалить элемент массива и возвращаемое значение pop(i) чаще просто игнорируется. [При этом вводить отдельный метод, который только удаляет элемент массива и ничего не возвращает, нежелательно: во-первых, это плодит сущности, и во-вторых, наиболее подходящее название для такого метода — remove() — уже занято в Python под другое действие.] Теперь представим себе функцию, которая оканчивается строкой a.pop(i). Что хотел программист в данном случае? Просто удалить элемент из a, или удалить и вернуть его?
Хочу заметить, что в Rust и Haskell эта проблема не актуальна, так как в первом необходимо всегда явно указывать тип возвращаемого функцией значения, а второй является функциональным языком, в котором с массивами так не работают. А в языках, где факт возвращения значения функцией можно не указывать (в Python, в 11l) целесообразно всегда ставить «return» для возврата значения.
Для Rust можно ещё встретить такой аргумент:
https://www.reddit.com/r/rust/comments/14ony3g/...:

It's kind of just syntactic sugar. But it does make closures easier to deal with:
nums.iter().map(|i|*i).collect()

Without implicit return,
|i|*i
becomes
|i|{return *i;}
, which imo seems unneeded.
Но он не актуален в 11l, т.к. лямбды в 11l могут состоять только из одного выражения и не требуют «return».

Вызов функций без побочного эффекта между f(x) и «return» (а это функции g(y) и h(z)) не имеет никакого смысла.
Разработчики современных языков поступают обычно проще — игнорирование возвращаемого значения функции, не производящей побочный эффект (либо с атрибутом nodiscard/mustuse или аналогичным), является ошибкой компиляции, т.к. вызов функции в этом случае действительно не имеет смысла.

2023-09-24 Автор сайта

код для сохранения данных в файл, состоящий из вызовов «fwrite», можно скопировать, а затем заменить «fwrite» на «fread», и получится код для чтения из файла. Этот код, конечно, потребует доработки, но переставлять аргумент FILE* по крайней мере будет не нужно.
Компания PVS-Studio в своих рассказах о том, как ошибаются программисты, часто критикует копипастный подход: он является источником большого количества ошибок. Хотя само занятие программирование можно считать главным источником ошибок :) Бухгалтеры, водители, повара ошибаются не так часто, как программисты :)

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

Хотя можно схитрить. Ключевым словом «функция» обозначать только чистые функции. Для функций с побочными эффектами позаимствовать старое доброе слово «процедура». Остаётся подобрать ключевое слово для недетерминированных функций (может, прямо так и назвать, только сделать сокращение, например «недет»?) и для функций, сочетающих недерерминированность и побочные эффекты («подпрограмма»?).

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

Но дело даже не в «return», а в том, что к разным категориям функций применимы разные оптимизации.

При этом вводить отдельный метод, который только удаляет элемент массива и ничего не возвращает, нежелательно: во-первых, это плодит сущности, и во-вторых, наиболее подходящее название для такого метода — remove() — уже занято в Python под другое действие.
Всё-таки это не по фэншую — позвать девушку на танец и не танцевать. Сказать, что мне всего лишь надо было освободить место около стены, где Вы стояли. А что страшного, если будет дополнительная функция будет носить имя «delete» или «drop» (как в Форте)?

2023-09-27 alextretyak

Хотя можно схитрить. Ключевым словом «функция» обозначать только чистые функции. Для функций с побочными эффектами позаимствовать старое доброе слово «процедура».
Так уже сделано в языке Nim: ключевое слово func является псевдонимом proc {.noSideEffect.}.
Но как отмечено там же: термин "побочные эффекты" не имеет чёткого определения ("Side effect" has no precise definition).

В целом я склоняюсь к тому, что спецификаторы функций должны определяться автоматически компилятором (речь идёт о таких спецификаторах как inline, pure, а также const у методов). А то иначе получится как в D: например у функции analyzeHandHelper из примера отсюда аж 4 спецификатора/атрибута (pure nothrow @safe @nogc) не считая private, а у вышестоящей функции analyzeHand спецификаторы/атрибуты nothrow и @safe почему-то закомментированы (неужели эта функция не-safe и может бросить исключение?).
И такие спецификаторы как consteval и constexpr в C++ я также считаю излишними: любую функцию, аргументы которой вычислимы на этапе компиляции, компилятор должен пытаться выполнить на этапе компиляции.

2023-09-27 veector

В целом я склоняюсь к тому, что спецификаторы функций должны определяться автоматически компилятором (речь идёт о таких спецификаторах как inline, pure, а также const у методов).
любую функцию, аргументы которой вычислимы на этапе компиляции, компилятор должен пытаться выполнить на этапе компиляции.
Раз уж делимся своими мыслями исходя из опыта разработки, то и я немного поделюсь своими.
  1. Лично я сторонник управляемой автоматики, что бы автоматика делала автоматически только тогда, когда надо, и не делала тогда, когда не надо. Соответственно, я, как разработчик софта, желаю, что бы компилятор меня проконтроллировал, если я вдруг написал функцию, которая не смогла вычислиться на этапе компиляции, то компилятор обязан меня об этом предупредить, т.к. это может быть просто ошибка в исходном коде.
  2. Что касается inline-фнукций, считаю, что допущена более фундаментальная ошибка. Считаю, что решать inline/не inline, должен тот, кто пользуется функцией (по своим критериям), а не тот, кто пишет саму функцию. Например, в одном месте программы мне надо самый быстрый вариант, а значит мне надо что бы было вставлено тело функции (и желательно раскрыты циклы), а не её вызов. В остальных частях программы, мне надо экономить размер кода, поэтому, я желаю что бы компилятор вставлял вызов функции.

PS. В C++ мне вообще ничего не нравится, предпочитаю чистый Си.

2023-09-28 Автор сайта

Да, есть два подхода к свойствам функций, оба имеют как преимущества, так и недостатки.

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

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

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

любую функцию, аргументы которой вычислимы на этапе компиляции, компилятор должен пытаться выполнить на этапе компиляции.
Не любую, а только чистую. Иначе бы на этапе компиляции можно было бы выполнять такие функции:
x = random()   // аргумент известен во время компиляции, он пустой
put (y)        // аргумент известен во время компиляции, это y

2023-09-30 alextretyak

2. Что касается inline-функций, считаю, что допущена более фундаментальная ошибка. Считаю, что решать inline/не inline, должен тот, кто пользуется функцией (по своим критериям), а не тот, кто пишет саму функцию. Например, в одном месте программы мне надо самый быстрый вариант, а значит мне надо что бы было вставлено тело функции (и желательно раскрыты циклы), а не её вызов. В остальных частях программы, мне надо экономить размер кода, поэтому, я желаю что бы компилятор вставлял вызов функции.
Фундаментальная ошибка inline-функций в том, что по факту все оптимизирующие компиляторы C++ на спецификатор inline просто не смотрят: решение о встраивании или не встраивании функции принимается ими автоматически и не зависимо от наличия/отсутствия этого спецификатора.
Но если очень надо, то есть __forceinline/__attribute__((always_inline)) для принуждения встраивания функции и __declspec(noinline)/__attribute__((noinline)) для запрета встраивания. Последний можно указать у вспомогательной функции, которая только вызывает встраиваемую функцию:
__forceinline auto inlined_fn(<some arguments>)
{
    ...
}

template <class... Args> __declspec(noinline) auto not_inlined_fn(Args&&... a)
{
    return inlined_fn(std::forward<Args>(a)...);
}
Соответственно, если хочется экономить размер кода, то нужно вызывать not_inlined_fn(), которая не будет встраиваться.

любую функцию, аргументы которой вычислимы на этапе компиляции, компилятор должен пытаться выполнить на этапе компиляции.
Не любую, а только чистую.
Я думал об этом. И написал так как написал осознанно. Т.к. практическое программирование порой противоречит теоретическим идеальным концепциям вроде «чистоты функций»: например, может быть вполне себе чистая функция, выполняющая какие-то очень сложные вычисления, но у которой есть булевый аргумент debug, который программист добавил для возможности отладки работы этой функции. Т.е. если аргумент debug равен true, тогда функция что-то пишет в лог или выводит в консоль или изменяет какую-то глобальную переменную, и таким образом формально уже не является чистой функцией. Но я не вижу причин, которые бы помешали компилятору при вызове этой функции с debug установленным в false считать эту функцию чистой и вычислять её на этапе компиляции при условии, что все аргументы этой функции вычислимы на этапе компиляции.

Иначе бы на этапе компиляции можно было бы выполнять такие функции:
x = random()   // аргумент известен во время компиляции, он пустой
put (y)        // аргумент известен во время компиляции, это y
Вот кстати. А почему бы и нет?
Вызов random() без предварительного srand(time(0)) [или чего-то похожего] обычно не используют. Но если используют, то именно для того, чтобы получить детерминированный random — а это полезно например для рефакторинга/переписывания какого-нибудь алгоритма, использующего random, чтобы можно было удостовериться, что алгоритм после переписывания работает правильно. Например, есть алгоритм генерации шума, который использует функцию random. Как понять, что после оптимизации кода алгоритм выдаёт такой же результат? Только сравнив шум, полученный старой версией алгоритма и новой версией. Что возможно только при условии детерминированности random. (На практике я довольно часто использовал детерминированный random (при этом саму функцию я называл nonrandom :)(:) для проверки корректности автоматизированного перевода задач на Rosetta Code с языка Python на 11l, как например вот в этой задаче [вот соответствующий код на Python].)
И если компилятор видит, что seed, к которому обращается код внутри функции random(), нигде в программе не устанавливался [или устанавливался в значение, известное на этапе компиляции] и не изменялся, то такой random() можно вычислить на этапе компиляции.

А касательно вызова put (y), то разумеется, компилятор не должен выводить y на этапе компиляции. Но он может предвычислить какие-то выражения внутри функции put, чтобы put выполнялась быстрее — например перевести y в десятичную систему счисления на этапе компиляции, что немного ускорит скомпилированную программу.

В целом, тут ситуация чем-то напоминает внеочередное исполнение (out-of-order execution) машинных инструкций в современных процессорах: для повышения производительности процессор может переупорядочивать инструкции, но итоговый результат должен быть в точности таким же, как если бы инструкции выполнялись процессором строго по порядку одна за другой [цитата отсюда: «процессор записывает результаты выполнения инструкций так, чтобы создавалась видимость нормального, очередного выполнения.»]. Так и оптимизирующий компилятор может выполнять всё что ему вздумается на этапе компиляции (в том числе вычислять любые функции: как полностью, так и частично [если функции не полностью чистые]), главное чтобы результат был таким же, как и при традиционном исполнении кода, т.е. таким же как ожидает программист (лишь с той разницей, что получается этот результат за меньшее время выполнения скомпилированной программы).