Дефекты языка

В 1994 году автор попытался разобраться в том, как работает компилятор и написать такую программу. Вопрос о входном языке не был главным и на том уровне понимания не мог быть разумно решен. Но что-то решить было необходимо и было подготовлено очень короткое словесное описание. Формальное описание отсутствовало, но для ручного написания компилятора оно и не требовалось.

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

Позже автор несколько раз возвращался к этим вопросам. В 2001 году было реализовано объектное расширение, в 2003 году компилятор был полностью переписан, но язык существенных изменений не претерпел (точнее, он уменьшился - не были реализованы работа с вещественными числами и объектное расширение). В 2005 году из языка было выделено очень ограниченное подмножество и реализован соответствующий компилятор. В это время появилось формальное описание языка, сделано оно было по настоянию редактора журнала "Мир ПК", в котором компилятор был опубликован. В 2008 году описание пригодилось - был написан почти такой же маленький таблично-управляемый компилятор. Не понимаю, почему в соответствующих учебниках нет такого примера.

Устройство компилятора больше не тайна и можно попытаться критически рассмотреть его входной язык. По-крайней мере некоторые его аспекты.

Отсутствие различий между ссылками на простые объекты и ссылками на массивы объектов

Ссылка в Context-е похожа на указатель в языке C и предназначена для тех же целей, в частности, для передачи параметров функциям и для реализации строк. Ссылка является адресом объекта и одномерного массива объектов, спрособа различать эти две вещи нет. Например, если функция F имеет строковый параметр:


  void F(char @P)
    ...
  end

ничто не мешает передать ей ссылку на единственный символ


  begin
    char C;
    F  (@C);
  end

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

Отсутствие ссылок на многомерные массивы

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


  define N 10

  struct ROW
    real Row[N];
  end

  ROW    Matrix[N];

  begin
    int    J  := 1;
    int    K  := 2;

    Matrix[J].Row[K] := 0.0; // Matrix[J][K] := 0.0;
  end

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

Многомерный массив можно имитировать с помощью одномерного и явного вычисления смещения:


  void DoSome(int N; real @PM)
    int    J  := 1;
    int    K  := 2;
 
    PM[N*J+K] := 0.0;        // PM[J][K] := 0.0;    
  end

  define N 10

  real   Matrix[N*N];

  begin
    DoSome(N, @Matrix);
  end

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

Наконец, можно использовать ссылки второго порядка (для массивов болшей размерности - ссылки более высоких порядков):


  void DoSome(int N; real @@PM)
    int   J   := 1;
    int   K   := 2;
    
    PM[J][K]  := 0.0;
  end

  define N 10

  struct ROW
    real Row[N];
  end

  ROW    Matrix[N];

  real  @PV    [N];          // Массив ссылок на массивы чисел
  real @@PM;

  begin
    int   I  := 0;           // Инициализация
    while I   < N do
      @PV[I] := @Matrix[I].Row;
      inc I;
    end

    @@PM     := @@PV;

    DoSome(N, @@PM);
  end

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

Точно такая же проблема существует в языке C:


  #define  N 3

  void DoSome(int N, double **PM)
  {
    int     J  = 1;
    int     K  = 2;

    PM  [J][K] = 0.0;
  }

  struct   ROW
  {
    double Row[N];
  };

  struct   ROW Matrix[N];

  double  *PV [N];
  double **PM;

  void main()
  {
    int    I = 0;
    while (I < N)
    {
      PV[I] = (int *) &Matrix[I].Row;
      I++;
    }

    PM = (int **) &PV;

    DoSome(N, PM);
  }

В C++ можно использовать механизм шаблонов:


  template <int N>

  void DoSome(double M[][N])
  {
    int     J  = 1;
    int     K  = 2;

    M   [J][K] = 0.0;
  }
  
  double M10x10[10][10];
  double M10x20[10][20];
  double M20x20[20][20];

  void main()
  {
    DoSome(M10x10);
    DoSome(M10x20);
    DoSome(M20x20);
  }

В данном случае компилятор сгенерирует две реализации функции DoSome - для матриц шириной 10 и 20. Высота матрицы не задается, так что если матрица не квадратная, высоту нужно передавать явно. Ширину матрицы передавать не нужно, т.к. она равна значению константы N.

Наконец, можно совместно использовать шаблоны и перегрузку операторов:


  template <int N, class C>

  class Vector
  {
    private:
      C c[N];

    public:
      C &operator [](int I)
      {
        return c[I];
      }
  };

  template <int N, class C>

  void DoSome(Vector<N, Vector<N, C> > &M)
  {
    ...
  }

  void main()
  {
    const int N = 10;

    Vector<N, Vector<N, double> > M;

    DoSome(M);
  }

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

В стандартном Pascal также нет необходимых средств, зато в языке Oberon работа с многомерными массивами не является проблемой:


  MODULE arrays;

  PROCEDURE doSome(VAR m: ARRAY OF ARRAY OF REAL);
  VAR
    j: INTEGER;
    k: INTEGER;
  BEGIN
    j := 1;
    k := 2;

    m[j][k] := 0.0
  END dosome;

  PROCEDURE test*;
  CONST
    n = 10;
  VAR
    m: ARRAY n OF ARRAY n OF REAL;

  BEGIN
    doSome(m)
  END test;

  END arrays.

Размеры массива передаются процедуре doSome неявно, так что ошибиться здесь нельзя. Из перечисленных языков только Oberon предоставляет лучшие средства работы с многомерными массивами, чем Fortran:


   REAL MATRIX(10, 10)

   CALL DOSOME(10, M)
   CALL DOSOME(20, 20)

   END


   SUBROUTINE DOSOME(N, M)
     INTEGER  N
     REAL     M(N, N)

     RETURN
   END

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

Ссылки на ссылки

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


  struct    NODE
    word    ID;
    word    Value;
    NODE   @Left;
    NODE   @Right;
  end

  ...

  begin
    ...
    NODE @Node := NULL;
    NODE @@P   := @@Node;
    while strcmp(@Scan(@Buff), "end") != 0 do
      @P  := @Ctrl(@Buff);
      @@P := @@P.Right;
    end
    ...
  end

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


  begin
    ...
    NODE @Node := NULL;
    NODE @P;
    while strcmp (@Scan(@Buff), "end") != 0 do
      if @Node = NULL then
        @Node    := @Ctrl(@Buff);
        @P       := @Node;
      else
        @P.Right := @Ctrl(@Buff);
        @P       := @P.Right;
      end
    end
    ...
  end

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


  begin
    ...
    NODE @Node := @Ctrl(@Scan(@Buff));
    NODE @P    := @Node;
    while strcmp (@Scan(@Buff), "end") != 0 do
      @P.Right := @Ctrl(@Buff);
      @P       := @P.Right;
    end
    ...
  end

Ссылки второго порядка могут использоваться для инициализациии ссылок первого порядка посредством вызова функции:


  void Init(char @@P)
    @P := "qwerty";
  end

  begin
    char  @P;
    Init(@@P);
  end

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

Недостаточные средства разделения програмы на части

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

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

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

Различные пространства имен для параметров и локальных переменных

Локальная переменная с тем же именем, что и параметр функции делает параметр недоступным и это может быть источником ошибок.

Константы

Представление шестнадцатеричных чисел и символьных констант было заимствовано из Turbo Pascal:


  define VSEG  $B800      // Сегментный адрес видеопамяти
  define LF    #10        // Символ перевода строки

Шестнадцатеричные константы в ряде случаев удобны, двоичные константы пожалуй еще более удобны для записи команд процессора, полезность записи констант в иных системах счисления неочевидна и может приводить к ошибкам (в языке C константа 010 - это не 10, а 8). Символов наподобие $ не так много и их связь с основанием системы счисления неочевидна, поэтому имеет смысл использовать другую нотацию. Как вариант, константа, представленная в отличной от десятичной системе счисления может начинаться с нуля, за которым следует символ, определяющий основание:


  define VSEG  0hB800     // Сегментный адрес видеопамяти в шестнадцатеричной системе счисления
  define NOP   0b10010000 // Пустая команда в двоичной системе счисления

Что касается символьных констант, использование решетки (#) было нужно только для ввода неотображаетых символов, например, символа конца строки (#0). Использование тильды (~) решает этот вопрос так же, как обратный слеш (\) в языке C:


  define EOL   '~0'       // Символ конца строки
  define CR    '~r'       // Символ возврата каретки
  define LF    '~n'       // Символ перевода строки
  define QUOTE '~''       // Кавычка
  define TILDE '~~'       // Тильда

Не для всех символов определены замены но ничто не мешает это сделать. Обратный слеш не использован, поскольку в DOS/Windows он является разделителем в имени файла.

Целые со знаком и без

В качестве основного типа было выбрано машинное слово без знака (word). В ряде случаев числа со знаком неободимы (например, координаты левого верхнего угла окна на экране - целые со знаком), в некоторых случаях они удобнее, но они ограничивают максимально допустимую длину массива байт. В шестнадцатиразрядных системах это может быть существенно - разница между 32 и 64 килобайтами гораздо больше, чем между 2 и 4 гигабайтами. Если же говорить только о написании компилятора, то отсутствие целых без знака принципиальных трудностей не создает, но для хранения смещений вместо одного слова без знака придется использовать пару слов со знаком, а для работы с парами чисел придется написать специальные арифметические функции - встроенными арифметическими операторами уже не обойтись. Для хранения создаваемого кода можно использовать массив символов (char) или знаковых байт (byte) и преобразование в них из целых со знаком (int).

Выбор не между знаковыми и беззнаковыми числами, а между наличием и отсутствием беззнаковых чисел. Оба варианта имеют достоинства и недостатки. При наличии обоих видов чисел нужно определить правила преобразования. В Context'е беззнаковое число преобразуется в знаковое:


//16 бит
//i >= w
  begin
    int  i := 32767;
    word w := 32768;
    if   i < w then
      puts("i <  w");
    else
      puts("i >= w");
    end
  end

В языке C знаковое число преобразуется в беззнаковое:


  #include <stdio.h>

  void main()
  {
    int      i = -1;
    unsigned u =  1;
    printf("%i %s %d\n", i, i < u ? "<" : ">=", u); // -1 >= 1
  }

Оба варианта формально некорректны, но преобразование беззнакового числа в знаковое не приводит к проблемам, если число невелико. Обратное преобразование небольшого числа к проблемам приводит. Отсутствие беззнаковых чисел также может приводить к проблемам (реальный код, написанный человеком, недавно начавшим изучение языка Java):


  public final class Utils
  {
  //...

    /**
     * Метод преобразует массив байт в строку без завершающих пробелов.
     */
    public static String rtrim(byte buff[], String characterSetName) throws UnsupportedEncodingException
    {
      int    n = buff.length;
      while (n > 0 && buff[n - 1] <= ' ')
      {
        n--;
      }

      if (n == 0)
      {
        return null;
      }

      return new String(buff, 0, n, characterSetName);
    }
  }

Этот метод понадобился для чтения dbf-файлов (да, они до сих пор используются) и был написан по аналогии с методом trim класса String:


  public final class String
  {
  //...

    public String trim() {
        int len = count;
        int st = 0;
        int off = offset;      /* avoid getfield opcode */
        char[] val = value;    /* avoid getfield opcode */

        while ((st < len) && (val[off + st] <= ' ')) {
            st++;
        }
        while ((st < len) && (val[off + len - 1] <= ' ')) {
            len--;
        }
        return ((st > 0) || (len < count)) ? substring(st, len) : this;
    }
  }

Неприятность в том, что buff - массив байт со знаком, соответственно все коды от 0x80 до 0xFF считаются отрицательными и меньшими кода пробела. И приводит это к потере части или всего русского текста. Можно конечно сказать, что символы - это не числа и сравнивать их друг с другом нельзя, но в Java это не так и замена символа пробел числом 32 ничего не меняет. Символы же использовать нельзя, поскольку они двухбайтовые. Возможно, правильнее было сначала преобразовать массив байт в строку, а потом удалять пробелы, но дело не в этом - команды процессора тоже могут быть байтами без знака.



Сайт создан в системе uCoz