суббота, 14 января 2017 г.

Как бы я изменил язык Си в 2017 году

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

Введение


Для начала объясню чем мне нравится Си - он простой, предсказуемый, быстрый язык, который позволяет взять и сделать то что тебе нужно. Если писать по стандарту, то вероятность огрести неадекватную плоходиагностируемую проблему мала. Но к сожалению, Си позволяет писать не по стандарту, что приводит к большому количеству проблем. Более того некоторые пункты стандарта написаны не лучшим образом, некоторые его пункты не отвечают современным требованиям. Поэтому мне хотелось бы сохранить простоту и скорость языка, устранив из него устаревшие или неправильные на мой взгляд моменты. Возможно при этом добавить ещё несколько простых возможностей.

Сначала пройдёмся по уже существующим возможностям. Чтобы упростить чтение подчёркиванием я выделил основной вердикт по тому или иному пункту.

Убрать или изменить

 

Преобразование указателя в целое и наоборот


Начнём с не самого очевидного пункта, но от него надо избавиться вообще полностью. Почему это вдруг мешает? Во-первых результат таких действий implementation-defined, т.е. зависит от реализации компилятора. Здесь очень хорошо расписано к чему могут приводить такие операции. Есть ещё одна вещь, о которой почти никто не задумывается - потеря информации о типе указателя. В Эльбрусе, например, существует аппаратная возможность контроля типов, но такое приведение указателя полностью перечёркивает её.

Приведение типов указателей


Это очень частый источник ошибок, которые игнорируются программистами. Кратко: в Си нельзя к переменной типа int обращаться через указатель типа float:

int i;
float * f = (float *) &i;
*f = 5.0; // Undefined behaviour


К сожалению мало того что компилятор позволяет творить такое безобразие, его ещё часто используют на практике. От него надо также полностью избавиться.

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

Неявные приведения типов


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

Синтаксис typedef


На данный момент он ужасен. Как только нам надо сделать alias для указателя на функцию жизнь превращается в боль. Сейчас это выглядит примерно так:

typedef void (*SignalHandler)(int);

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

alias SignalHandler = * (void)(int);

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

void в сигнатуре функции


Сейчас если функция не содержит аргументов, то необходимо писать void в её сигнатуре, иначе компилятор будет считать что это K&R стиль и множество оптимизаций от неё тупо отвалят:

int foo() {return 1;} // K&R - плохо
int bar(void) {return 1} // Си - хорошо


Это историческое наследие, от которого давно пора избавиться.

Прототипы функций


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

Проблемы с enum


Ещё один источник проблем - то что enum не является отдельным типом. На самом деле это int, что тоже приводит к ошибкам. В частности, есть возможность присвоения типа int объекту типа enum, и, что ещё хуже, есть возможность присвоения значения объекта одного enum'а объекту другого enum'а. Такие вещи должны быть запрещены. Сам enum - самостоятельный тип со всеми вытекающими.

Signed и unsigned типы


Есть с ними интересная проблема. Так по стандарту signed int не может переполняться, значит компилятор всегда подразумевает что поведение таких переменных предсказуемо и всегда справедливо неравенство: (i+1) > i. С unsigned всё не так, и мы не можем исходить из такого предположения. Это не позволяет применяться некоторым оптимизациям. Сейчас мне видится что их поведение должно быть унифицировано и переполнения должны быть исключены.

Массивы переменной длины (vla)


В c99 были введены variable length arrays - массивы переменной длины. Это объект, память под который выделяется на стеке, но при этом его размер неизвестен во время компиляции. Особо много проблем они доставляют если помещать их в середине структуры (непонятно как считать её размер, как вообще это обрабатывать). Да и просто работа оптимизаций с ним крайне затруднительна. В нормальном языке VLA быть не должно.

union


Тоже очень больная тема для Си. Для них стандарт прописан очень криво и муторно, основная проблема с ними в том что в одной области памяти могут лежать данные разных типов, и во время компиляции мы не знаем конкретный тип на данный момент. Совсем кошмар начинается если на union внезапно берут указатель (а ещё хуже если на его поле). Тогда компилятор полностью теряет возможность отслеживать происходящее. У меня есть понимание что union'ы нужны, но пока нет понимания как их грамотно сделать.

Глобалы


Глобалы нужно запретить. Никаких extern int. Самое глобальное что только можно делать - static объекты, которые видны только внутри модуля. Если кому-то понадобится прочитать/записать глобальное значение, то это очень легко реализуется через extern функции, меняющие static объект.

Арифметика указателей


Тоже довольно интересный момент. Она даёт большую гибкость в работе с памятью, но в реальности выливается в совершенно уродливое хаккерство, нарушающее стандарт и убивающее переносимость. Для всех объектов и типов (кроме, возможно char) её следует запретить.

Конструкция switch


Сейчас она ужасна и приводит к ошибкам, её нужно полностью переделать. Во-первых каждый case должен быть отдельным лексическим блоком, окончанием которого должен быть break. Во-вторых имеет смысл добавить нормальный синтаксис для перечисления диапазонов значений switch. Нужно всегда явно требовать default ветки.

inline


Убрать. Сейчас компиляторы всё равно по дефолту игнорируют это ключевое слово. В реальности же программист сам не может знать нужно делать подстановку функции или нет, часто это приводит к деградациям. Этим вопросом должен заведовать компилятор. Также неплохо бы избавиться от других устаревших ключевых слов (register, auto и т.п.).

Макросы


С ними тоже очень неоднозначная ситуация. Макросы очень полезны для условной компиляции, поэтому в том или ином виде я бы оставил #ifdef и #if. Я бы полностью избавился от #include. Ещё необходимо полностью запретить конкатенацию макросов - генерация имени функции в compile time это сущий ад, за такое хочется убивать. Далее #define. С одной стороны он позволяет делать функции высшего порядка. Например мы в качестве аргумента можем подавать участки кода:

#define debug(actions) \
{ \
    if ( enablePrint ) \
    { \
        actions; \
    } \
}

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

goto, longjump


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

Система сборки


Текущая система сборки Си не отвечает современным требованиям. В Си есть "единица трансляции" - один модуль, т.е. .c файл. Компилятор генерирует из них объектный файл, потом линкует. Такая схема приводит к множеству проблем. Про проблемы с сигнатурами функций я уже писал, более того это приводит к зависимости от порядка линковки! Ну и как бонус - такая система не позволяет делать межмодульные оптимизации, что не позволяет нормально оптимизировать программы. Современный компилятор для современного языка должен собирать всё в режиме "вся программа". Это более продвинутая (и более сложная) техника чем lto, но только так можно обеспечить качественные и быстрые приложения. Тут есть проблемы с библиотеками (особенно подключением динамических библиотек), пока что я не знаю как их разрешить.

One Definition Rule


Как следствие из предыдущего пункта в языке должен действовать ODR. Это правило есть в C++, оно говорит о том что в лексическом блоке одному имени может соответствовать только одна реализация класса. Это правило должно быть обязательно.

static


На данный момент все переменные вне функций и сами функции неявно считаются extern'ами, т.е. видны другим модулям. По умолчанию функции должны быть static, глобалы вообще могут быть только static.

Подсказки компилятору


Сейчас подобные вещи реализиуютсячерез #pragma или через __attribute__. Я бы убрал оба варианта и сделал унифицированный способ подачи метаинформации. Пока сложно сказать как это должно выглядеть, потому как метаинформация может быть нужна для типов, для объектов, для синтаксических конструкций.

Unspecified и Implementation-defined behavior


В Си существует три типа неопределённого поведения: unspecified, implementation-defined и undefined. Первые два типа я бы убрал полностью. Если же компилятор может статически доказать undeifned behavior, программа не должна собираться.

Добавить


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

JIT


Под jit может подразумеваться несколько вещей, поэтому поясню. Во-первых мне кажется интересной возможность выполнить eval в языке. Т.е. скомпилировать строку прямо во время исполнения и обращаться к фунциям и неё. Ещё одной возможностью является перекомпиляция функций если во время исполнения выясняется что они были соптимизированы неоптимально. Это довольно сложная фича и у меня пока нет понимания возможно ли её реализовать "малой кровью", т.е. без переноса исполнения в виртуальную машину.

Обобщённые функции


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

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

Классы


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

Параметры по умолчанию, именованные параметры


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

Инициализация полей структуры


Хотелось бы иметь возможность делать так:

typedef struct {
int a = 1;
float b = 2.0;
} MyStruct_t;


Неизменяемые поля


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

Вложенные комментарии


Можно спокойно жить и без них, но мне кажется это было бы удобно.

Многострочные строки


В python есть отличная возможность создавать много строчные литералы:

"""
текст
текст
текст
"""


Хотелось бы иметь такою же возможность в своём языке.

Синтаксис для регулярных выражений


В C++11 был добавлен специальный синтаксис для описания регулярных выражений:

regex integer("(\\+|-)?[[:digit:]]+");

В он был бы крайне полезен.


Заключение


В этом посте я поразмышлял над тем каким я хотел бы видеть Си, что поменял бы в нём. Это очень субъективный пост, на которой во многом повлияло то что я занимаюсь разработкой компилятора. Когда я только начинал думать на этот счёт, казалось что я получу просто более строгий Си, но в реальности получается принципиально другой язык.

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

Отправить комментарий