Начнем с описания синтаксиса объявления переменных в С-подобных языках. В С было решено отказаться от отдельного синтаксиса описания переменных и позволить объявлять переменные как выражения:
int x;
Как мы видим, тип переменной стоит слева, затем имя переменной. Благодаря этому мы максимально приближаем объявление переменной к обычному выражению. Допустим, к такому:
int x = 5;
Или такому:
int x = y*z;
В принципе, все просто и понятно, и вполне логично, посмотрим на определение функций в C.
Изначально в C использовался вот такой синтаксис определения функции:
int main(argc, argv)
int argc;
char *argv[];
{ /* ... */ }
Типы переменных описывались не вместе с именами аргументов, но потом синтаксис заменили на другой:
int main(int argc, char *argv[]) { /* ... */ }
Здесь все тоже достаточно просто и понятно. Но это удобство начинает испаряться, когда в дело вступают указатели на функции и функции, которые могут принимать указатели на них.
int (*fp)(int a, int b);
Здесь fp — ссылка на функцию, принимающую 2 аргумента и возвращающая int. В принципе, не сложно, но вот что будет если одним из аргументов будет ссылка на функцию:
int (*fp)(int (*ff)(int x, int y), int b)
Уже как-то сложновато или вот такой пример:
int (*(*fp)(int (*)(int, int), int))(int, int)
В нем, если честно, я заблудился.
Как видно из описания, при декларировании указателей на функции в языках С есть существенный недостаток в читаемости кода. Теперь посмотрим, какой метод предлагает использовать для чтения определения переменных в С Дэвид Андерсон(David Anderson). Чтение происходит по методу Clockwise/Spiral Rule (часовой стрелке/спирали).
Данный метод имеет 3 правила:
- Чтение начинается с неизвестного элемента движением по спирали;
- Обработка выражения по спирали продолжается пока не покроются все символы;
- Чтение необходимо начинать с круглых скобок.
Пример 1:
Следуя правилу, начинаем с неизвестной str:
- Двигаемся по спирали и первое, что мы видим, это символ ‘[’. Значит, мы имеем дело с массивом
— str массив 10-и элементов;
- Продолжаем движение по спирали и следующий символ это '*'. Значит, это указатель
— str массив 10-и указателей;
- Продолжая движения по спирали приходим к символу ';', означающий конец выражения. Двигаемся по спирали и находим тип данных char
— str массив 10-и указателей на тип char.
Возьмем пример посложнее
Пример 2:
- Первая неизвестная, которая нам встречается, это signal. Начинаем движение по спирали от нее и видим скобку. Это означает, что:
— signal – это функция которая принимает int и…
- Здесь мы видим вторую неизвестную и пытаемся проанализировать ее. По тому же правилу двигаемся от нее по спирали и видим, что это указатель.
— fp указатель на …
- Продолжаем движение и видим символ ‘(’. Значит:
— fp указатель на функцию, принимающую int и возвращающую…
- Идем по спирали и видим 'void'. Из этого следует, что:
— fp указатель на функцию, принимающую int и ничего не возвращающую;
- Анализ fp закончен и мы возвращаемся к signal
— signal – это функция, которая принимает int и указатель на функцию, принимающую int и ничего не возвращающую;
- Продолжая движение видим символ ‘*’, что означает
— signal – это функция, которая принимает int и указатель на функцию, принимающую int и ничего не возвращающую, и возвращает указатель на…
- Идем по спирали и мы видим ‘(’, что означает
— signal – это функция, которая принимает int и указатель на функцию, принимающую int и ничего не возвращающую, и возвращает указатель на функцию, принимающую int…
- Делаем последний виток и получаем следующее
— signal – это функция, которая принимает int и указатель на функцию, принимающую int и ничего не возвращающую, и возвращает указатель на функцию, принимающую int и возвращающую void.
Вот так, без особых усилий, предлагает нам читать определение переменных Дэвид Андерсон.
Теперь рассмотрим диаметрально противоположный синтаксис, когда тип переменной находится справа от имени переменной, на примере Go.
В Go переменные читаются слева направо и выглядят вот так:
var x int
var p *int
var a [3]int
Здесь не нужно применять никаких спиральных методов, читается просто
— переменная a — это массив, состоящий из 3-х элементов типа int.
С функциями тоже все достаточно просто:
func main(argc int, argv []string) int
И данное объявление тоже читается с легкостью слева направо.
Даже сложные функции, принимающие другие функции, вполне читаются слева направо:
f func(func(int,int) int, int) int
f — функция, принимающая функцию, которая, в свою очередь, принимает в параметрах 2 целых числа и возвращает целое число, и целое число, и возвращающая целое число.
Вот такие имеет отличия определение переменных в языках семейства C и Go. Очевидно, Go явно в этом выигрывает. Но если теперь вспомнить, какие языки выросли из старого доброго С – это С++, C#, Java — все они используют определение переменных такого типа. И они построены на парадигмах ООП и не используют (или практически не используют) передачу указателей на функции, все это нам заменили классы. Недостатки, которые выявляются у определения типа переменной слева, улетучиваются при использовании ООП.
Комментарии (56)
ComodoHacker
05.11.2015 08:19-2ЕМНИП синтаксис объявлений в C был сделан так, как удобнее разбирать компилятору. В Паскале же наоборот, как удобно читать человеку, за счет усложнения компилятора. В Go, видимо, нашли некий компромисс. :)
Sevlyar
05.11.2015 09:25+13Как раз наоборот: когда компилятор C++ строит AST и встречает int name..., он не знаете что будет дальше — объявление переменной или функции, поэтому эту информацию надо или запоминать где-то или возвращаться назад по исходнику. К тому же для языков с подобными грамматиками сложнее программировать восстановление после сбоев во время синтаксического анализа. Для паскаля как раз проще, для него отлично подходит обычный леворекурсивный парсер без наворотов.
Musia17
05.11.2015 08:36+2Сорри за оффтоп, но плиз, добавьте мягкий знак в слово «находитЬся» в заголовке.
NeoCode
05.11.2015 09:27+6Синтаксис объявления переменных в Си меня вполне устраивает, и я не вижу в нем ничего непонятного; то, что объявление совмещено с выражениями тоже очень удачно.
А вот для функций я бы предпочел ключевое слово func вначале, а возвращаемый тип после агрументов
func foo(int x) int
принцип очень простой: сначала пишем существующее, затем новое. То есть сначала пишем имя сущности, которая нам известна (ключевое слово или имя типа), а затем вводим новый идентификатор. Лично мне так гораздо понятнее.
Ключевые слова var и let, повсеместно используемые в новых языках (и не только в Go) для объявления переменных, очень удобны для компилятора. Они снимают любые неоднозачности, связанные с разбором: после них может быть только объявление переменных.
Удобны ли они для человека? Думаю, кому как, мне не очень. Но это дело привычки.
А вот объявление функций с ключевого слова было бы действительно удобно — по общему принципу с объявлением структур, классов, перечислений и т.д. Решалась бы путаница с указателями на функции. Упростилась бы работа компиляторов и IDE. Искать объявления функций в коде стало бы легче. Упростилась бы реализация объявления вложенных функций (напомню, еще в Паскале они были, а в современном С++ есть только частный случай в виде лямбд). Появились бы интересные дополнительные возможности: введение имен возвращаемых значений, введение специальных ключевых слов для специальных функций, удобный синтаксис для возврата сразу нескольких возвращаемых значений и т.д.Fedcomp
05.11.2015 10:04собственно в языке Rust приблизительно так и сделано:
doc.rust-lang.org/book/functions.htmlNeoCode
05.11.2015 10:34В языке Rust сделано почти так же как в Go: типы после имен переменных, ключевые слова let и let mut (вместо var), fn (вместо func) для фукнций.
monah_tuk
05.11.2015 11:54+1Видать комитету тоже так удобнее:
auto (*cb1)(int) -> int; auto proc(int x) -> int { return 31337; }
;-)
Для особых ценителей можно:
#define func auto func proc(int x) -> int;
khim
05.11.2015 13:32+1О нет. Это сделано нифига не для удобства. Просто в шаблонных функциях так бывает, что тип результата зависит от типа параметров — и тогда его описать до имени функции никак не получится!
А так да — можно использовать вполне и без шаблонов.monah_tuk
05.11.2015 17:38Да как бы да. Я прочитал свой пост, пока думал как лучше переписать — время вышло. Махнул рукой — кому нужно, тот поймёт :)
Chaos_Optima
06.11.2015 04:25Удобство использования стало как бы бонусом, описывать указатели на функции возвращающие функции стало удобнее
auto (*func_ptr)(int) -> auto (*)(float, int) -> int (*)()
gandjustas
06.11.2015 04:57Это проблема исключительно парсера С++. В C# прекрасно работает так:
IEnumerable<T> Where<T>(Funct<T,bool> predicate)
То есть T используется еще до указания, что тип-параметр.Chaos_Optima
06.11.2015 07:44Не тот случай, в С++ выражение по типу
template<class T> IEnumerable<T> Where(Funct<T,bool> predicate)
Тоже будут работать без всякого нового объявления. Новый тип объявления нужен в случае когда шаблонный тип один а возвращается совершенной другой. Например.
struct A{}; struct B { A func(); }; template<class T> auto Func(T& _val) -> decltype(_val.func());
Можно конечно извернутся и слепить нечто такое
template<class T> decltype(((T*)(0))->func()) Func(T& _val)
но это не совсем красиво, да и не уверен что будет работать везде и всегда.
Ну и да, не стоит забывать что в C# дженерики, а не шаблоны, они работают несколько иначе.gandjustas
06.11.2015 12:18Ничего что template указывается до любого объявления? Причем это сделано специально чтобы помочь парсеру. Не забывайте, что С++ использует LL парсер. А LL парсер хорошо работает когда смысл написанного правее зависит от того что написано левее. Поэтому и типы слева, и template писать надо. Можно было бы отказаться, но это бы усложнио парсинг и, скорее всего, увеличило бы время компиляции.
Ну и да, не стоит забывать что в C# дженерики, а не шаблоны, они работают несколько иначе.
Это к вопросу парсинга не имеет никакого отношения от слова вообще.khim
06.11.2015 13:15+2Это к вопросу парсинга не имеет никакого отношения от слова вообще.
имеет причём довольно-таки прямое. Так как у нас тип в дженериках предназначен для всяко-разных проверок и, в общем, не вляет на генерируемый код, то кроме типов в угловых скобках ничего указать нельзя. В C++ — можно, откуда и все беды.gandjustas
06.11.2015 13:38-2Парсинг строит синтаксическое дерево, ему по большому счету без разницы как потом это дерево обрабатывается. Вы вообще не о том говорите.
VoidEx
06.11.2015 13:51+2Насколько я понял, речь о том, что парсить вот такое в качестве возвращаемого типа в C++ — норма:
decltype(decltype(_val.func())::n + 10)::result_type
, а в C# — нетkhim
06.11.2015 15:00+3Нужно просто уметь парсить нечто зависящее от типа — а для этого нужно уметь понимать где у нас типы, а где — нетипы.
Я считал что этот пример всем, кто берётся рассуждать о тонкостях C++ известен.
Он просто очень выпукло показывают проблему во всей красе: в зависимости от опций компилятора у вас может быть по-разному построено синтаксическое дерево! Не выбраться другая функция и по другому посчитаться константа, нет — по разному будет именно всё разобрано. Без всяких ifdef'ов или define'ов (они-то вообще до компилятора отрабатывают и как бы «не в счёт»).
khim
06.11.2015 14:56Это вы не о том говорите. Возьмите всем известный пример:
Так вот в зависимости от того явзяется у васint x = confusing<sizeof(x)>::q < 3 > (2);
q
типом или переменной у вас будет построено разное синтаксическое дерево. Хабрапарсер выбирает один вариант (тот, который ему больше нравится), но там есть ещё и второй, где вначале считаетсяconfusing<sizeof(x)>::q < 3
и вот уже это сравнивается с двойкой.
В C# подобное невозможно потому что дженерики параметризуются только типами.gandjustas
06.11.2015 19:35Та же самое может быть в C#. Вместо имени типа может оказаться переменная и выражение
IEnumerable может быть воспрнято как (IEnumerable < T ) > что-то там, где IEnumerable и T — переменные. Но у C# грамматика более стройна и не допускает таких ошибок, после имени типа выражение не напишешь, нужно тип в скобки брать, что делает парсинг однозначным,
Например если для C++ запретить приведения типов в операторной форме, то подобной проблемы не возникнет. Да и многих других проблем можно избежать если поправить синтаксис, но из-за совместимости этого не делают.
Duduka
05.11.2015 13:32еще раз… в си тип размазан по определению, за исключением простых типов: в массиве — тип и размерность, в функции — возвращаемого и аргументов, указатели — привязанно к идентификатору, а не типу… а модификаторы… кто во что горазд.
batyrmastyr
05.11.2015 09:51-6В случае с Go некоторые примеры кода выглядят так, будто их скопировали из описания Компонентного Паскаля.
Да и зачем, спрашивается, тащить в новый язык полувековые дефекты и костыли, если можно взять что-то более продуманное, удобное и эффективное?NeoCode
05.11.2015 10:46+4Ну я бы не сказал что это полувековые костыли:) Да и кто сказал что в компонентном паскале костыли?
Костыли проявляются после более глубокого изучения языка, а подход типа «раз похоже на компонентный паскаль — значит костыли» совершенно неправильный.
Вот например кто нибудь знает, что в С/С++ (и также в C#/Java) есть дефект с приоритетом операций? Сможете назвать и обосновать?batyrmastyr
05.11.2015 11:10Костыли — это про сишный синтасис, причём судя по некоторым статьям — число дефектов в том же С++ год от года только растёт.
Athari
05.11.2015 14:58& и |?
<<?NeoCode
05.11.2015 17:14Операторы сравнения < <= > >= == != имеет приоритет выше чем битовые операции & | ^
В результате например вот такая вполне логичная конструкция
if(x & 0x07 > 4)
без скобок вокруг «x & 0x07» некорректна.matiouchkine
05.11.2015 17:27+1Чего это она некорректна? Очень даже она корректна. Это битовое умножение x и 1, то есть 0, если x=0 и 1 в противном случае.
NeoCode
05.11.2015 18:58+1Ну в смысле корректна, но бестолкова. Для действий с bool есть логические операции && и ||, которые совершенно правильно имеют приоритет ниже чем сравнения.
konsoletyper
05.11.2015 10:11+6f func(func(int,int) int, int) int
Вот зачем было в go изобретать велосипед, если уже давно в ML-образных языках используется синтаксис вроде
f : ((int, int) -> int, int) -> int
ИМХО, если хотелось сделать как привычнее, надо было брать синтаксис C. А тут хотели как лучше, явно посмотрели в сторону функциональных языков (да и не только их, любая статья по теории типов пестрит подобной нотацией), но почему-то не захотели вводить двоеточие и стрелочку.NeoCode
05.11.2015 10:37+1А зачем двоеточия и стрелочки, кроме как для красоты?
VoidEx
05.11.2015 10:49+1Принято так, вроде
Да и, имхо, нагляднее, чем func. Хотя, может, и дело привычки.
VoidEx
05.11.2015 10:54А, ну в ML-языках двоеточие потому, что запись через пробел (
f x
) — это применение функции (f(x)
)
kail
05.11.2015 11:23+2Перепишем вашу сложную функцию на Go
с указанием типа слеваf func(func(int,int) int, int) int
и увидим, что и так нет сложностей с прочтением.int f func(int func(int,int), int)
Даже если взять более сложную функцию из той же статьи.
f func(func(int,int) int, int) func(int, int) int
(int func(int, int)) f func(int func(int,int), int)
Так что дело не в бобине.zagayevskiy
05.11.2015 15:18+1int f func(int func(int,int), int)
Есть проблема с прочтением. В выделенном мной месте непонятно, что следует за int. Анализатору надо заглянуть вперёд, понять, что там func и только потом понять, что это аргумент-функция, а не аргумент-число. Так-то.kail
05.11.2015 16:11+1Да что вы все за анализатор то переживаете. На C++ пережевывает и не потеет. Читать люди будут. И у людей есть вполне очевидные проблемы с чтением сложных конструкций в C++.
А в Go что слева ставь тип, что справа – понять можно без проблем, а дальше уже вопрос вкуса и привычки.
gandjustas
05.11.2015 11:45+7Вот такие имеет отличия определение переменных в языках семейства C и Go. Очевидно, Go явно в этом выигрывает. Но если теперь вспомнить, какие языки выросли из старого доброго С – это С++, C#, Java — все они используют определение переменных такого типа.
Очень «тонкий» намек на преимущества Go, по сути бред.
Сложность типов в C вызвана тремя компонентами:
1) Указателями и повсеместным их использованием
2) Отсутствием нормального описания функционального типа
3) const
В C# и Java всего этого нет, поэтому и проблем с описанием типов нет. В С++ только const иногда мешает, да и то не часто. Так что никакого разительного преимущества Go перед современными языками нету.
Кстати писать тип после придумали в ML, лет за 40 до изобретения Go. Там даже еще дальше пошли — аннотации типов применяются не только к объявлениям, но и в выражениями. Это в сочетании с автоматическим выводом типов добавляет удобства в разы.BOBS13
05.11.2015 12:16+2Очевидно, Go явно в этом выигрывает. Но если теперь вспомнить, какие языки выросли из старого доброго С – это С++, C#, Java — все они используют определение переменных такого типа. И они построены на парадигмах ООП и не используют (или практически не используют) передачу указателей на функции, все это нам заменили классы. Недостатки, которые выявляются у определения типа переменной слева, улетучиваются при использовании ООП.
Вы, вырвали слова из контекста и все сказано в следующих 2-х предложения.gandjustas
06.11.2015 04:12-3Вы, вырвали слова из контекста и все сказано в следующих 2-х предложения.
Разве это изменило суть высказывания?
Расскажите что вы имели ввиду этой фразой.
RomanPyr
05.11.2015 12:40+1К слову, в GO указатели используются повсеместно. И проблем с типами нет :)
gandjustas
06.11.2015 04:11Я вот нашел в интернетах эквивалентные программы на C и Go, и на Go указателей не было вообще, а на C около 20 мест с указателями.
Причина простая — в C массив это указатель, а в Go используются слайсы. Ну и в Go вывод типов есть, а в C типы указываются явно.
roman_kashitsyn
05.11.2015 11:50+8Написание типа после имени — это необходимость, которую осознали слишком поздно. Такой подход, к примеру, позволяет компилятору выводить тип возвращаемого значения из типа аргументов функции. В C++11 даже (в очередной раз) ввели специальный синтаксис для этого:
template <class U, class V> auto add(U const& u, V const& v) -> decltype(u + v) { return u + v; }
Написатьdecltype(u+v)
вместоauto
нет возможности — там компилятору ещё не видны имена (и соответствующие типы) u и v.
Кроме того, как уже упоминалось, такой подход существенно упрощает как компилятор, так и разработку инструментов. Вспомним ключевое словоtypename
из C++:
template <class Iterator> void doSomething(Iterator it) { // Тут необходимо слово typename, чтобы компилятор мог понять, что вы хотите: // 1) объявить переменную v с типом указателя на Iterator::value_type; // 2) вызвать Itarator::value_type.operator*(v), где v нужно взять из окружающего контекста. typename Iterator::value_type * v; }
Если бы было ключевое слово для объявления переменных, такой проблемы бы не возникло:
var v: *Iterator::value_type; // объявление переменной Iterator::value_type * v; // умножение
Языку 30 лет, а мой текстовый редактор, к примеру, зачастую не может распознать объявления переменных. Если бы было слово var, риск ошибки был бы практически нулевой.
Ну и мне лично кажется, что подход с объявлением типа после имени делает опциональный вывод типов более логичным.
// Если тип опустить, то компилятор его выводит, логично. // К тому же, имена всегда выровнены по левому краю. var x = 5; var y: int = 5; var z: MyType = init(); // Хм... Ок... auto x = 5; int y = 5; MyType z = init();
NeoCode
05.11.2015 12:16+3Точно, тот самый знаменитый пример:)
X * Y; // что это - умножение или объявление указателя?
0xd34df00d
07.11.2015 20:11+1Нет,
typename
тут нужен не потому, что непонятно, умножение это или указатель.typename
нужен потому, что непонятно,value_type
— это тип или значение. Даже если вы уберёте звёздочку,typename
всё равно будет нужен.
Кстати, ещё кромеtypename
периодически нуженtemplate
. Например, в коде типа такого:
template<typename T> struct InstanceFunctor; template<typename T, typename F> using FmapResult_t = typename InstanceFunctor<T>::template FmapResult_t<F>; template<typename T, typename F> FmapResult_t<T, F> Fmap (const T& t, const F& f) { return InstanceFunctor<T>::Apply (t, f); } // Пример объявления FmapResult_t в конкретной специализации. template<typename T> struct InstanceFunctor<boost::optional<T>> { template<typename F> using FmapResult_t = boost::optional<ResultOf_t<F (T)>>; template<typename F> static FmapResult_t<F> Apply (const boost::optional<T>& t, const F& f) { if (!t) return {}; return { f (*t) }; } };
roman_kashitsyn
07.11.2015 23:55Я прекрасно понимаю, зачем и когда нужен typename. Я просто привёл самый популярный пример неоднозначности, которая возникала бы, если бы стандарт не предусматривал typename, и которой бы не было, если бы тип переменной шёл после ключевого слова и имени.
fareloz
05.11.2015 12:11-1int (*(*fp)(int (*)(int, int), int))(int, int)
пара typedef'ов обычно решает проблему нечитаемостиkhim
05.11.2015 13:28+2Пара typedef'ов обычно решает проблему нечитаемости
Ага, конечно. Особенно если выражение встречается не в коде, а в документации. Пример с сигналом — он же не из воздуха взялся, а из официальной документации.
Хорошо хоть названия параметров сохранились! По синтаксису они там не нужны, но выкидываниеfp
превратит выражение в паззл:
Вообще же — писать можно на чём угодно, хоть на брайнфаке, но то, что у вас выражение в полстроки невозможно понять и требуется сложный анализ производить — это же ненормально…void (*signal(int, void (*)(int)))(int);
Но вообще ворос: справа или слева не очень приниципиален. Можно слева (Java), можно справа (Go), главное — не со всех сторон сразу (как в C/C++). Описания переменных — это одно из мест в C/C++, которые сделаны очевидно плохо.
forketyfork
05.11.2015 16:33+1Спасибо за статью. Ещё, когда тип пишется справа, для меня это удачно укладывается в математическое представление типа как множества принадлежащих ему объектов.
var x int // x принадлежит множеству целых чисел var p *int // p принадлежит множеству указателей на объекты, принадлежащие множеству целых чисел var a [3]int // a принадлежит множеству трёхэлементных массивов объектов, принадлежащих множеству целых чисел
fshp
06.11.2015 02:20-5Где находиться типу: справа или слева?
Нигде. Типы должны выводиться автоматически. Если тип всё же нужно указать явно, то он должен быть указан для всего выражения.
andyN
06.11.2015 02:49+1Это очень субъективно. Мне например намного привычнее Сишный способ, первое время я вообще не понимал что там за мешанина в Go-коде.
Понятие удобства в данном случае очень сильно зависит от того, какой у человека бэкграунд. Единственный объективный способ сравнения — это посадить 100 программистов, которые никогда не писали на языках со строгой типизацией, и замерить сколько времени их мозг тратит на разбор си-образного и го-образного способов объявления типов. Все остальное — субъективщина и холиворы.
Sevlyar
Для C++ еще есть известный пазл: константный указатель/указатель на константу
Randl
Существует простое правило:
const модифицирует то, что написано прямо перед ним, за исключением (какой С++ без исключений) случая, когда это первое слово в строке. В этом случае, очевидно, модифицирует то, что прямо после.
И сразу легко понять что
это константный указатель на указатель на константный int.
samo-delkin
Хаха, это простое правило в стиле C++, а в стиле C правило звучит так «надо прочитать объявление в обратную сторону».
Randl
Тот же самый самый костыль в другой форме
Единообразия нет все равно.
«Правило в стиле С++» ничуть не сложнее, что модифицирует первый const знают все.