Лекция 10
Лекция 10
По настоящему переменный набор параметров поддерживают только C/C++ и С#.
Пример:
printf(const char *,…);
В этих языках есть специальные макросы для работы с переменным числом параметров:
![]() |
va_list
va_end
Рекомендуемые материалы
va_start
Общая идея: все параметры, которые загружаются в переменную list совместимы с целочисленными ТД или с указателями, а интерпретация остаётся на совести программиста, например, в printf(); параметры выбираются из стека в зависимости от информации, которая находится в процедуре в строке.
Это решение сразу стало критиковаться в связи с ненадёжностью, так как в этом случае ни компилятор, ни кто-то ещё не в состоянии произвести ни динамический, ни статический контроль. Зачем такая конструкция была введена (В С++ понятно из-за концепции совместимости)? Её наличием легко была решена проблема, связанная с вводом-выводом.
Даже в языках, не поддерживающих переменный список параметров, есть процедуры, которые реально поддерживают переменный список. Например, стандартный Паскаль: write(…); - это как бы псевдопроцедура (процедура, которую нельзя написать на самом языке), про которую компилятор знает достаточно много, отсюда у неё и получается переменный список параметров. Здесь же два подхода: вопрос удобства программирования (вывод разнородной информации) или языковая концептуальная целостность, упрощения языка и повышения надёжности. Такой подход принят в семействе языков, опирающихся на язык Паскаль: Модула 2, Оберон и так далее – нет процедур с переменным числом параметров. Надо вывести конструкцию вида: строка-int-строка. Оберон:
IMPORT InOut;
Это стандартный библиотечный модуль, содержащий все процедуры ввода-вывода.
InOut.WriteString(“count = “);
InOut.WriteInt(cnt);
InOut.WriteCh(‘.’);
InOut.WriteLn;
А на Си: printf(“count = %d.n”, cnt); и если мы забудем указать переменную cnt, то формально ошибки не будет, но будет напечатано какое-то число из стека (оно будет проинтерпретировано как целое число, загружающая программа загружает параметры в стек и их выгружает и, формально, никакого слома программы не произойдёт), чего при описанной выше системе ввода-вывода в принципе не может быть (мы либо просто забываем cnt, либо никаких ошибок быть не может). Но с другой стороны здесь язык становится несколько проще, и программировать становится удобней.
В С++ у нас есть класс CString, и его метод S.Format(“…”,…); реализует sprintf();. Многие программисты продолжают придерживаться этого подхода, следовательно, остаётся ненадёжность.
Один из подходов – статически параметризуемые ТД. Мы уже говорили о статическом перекрытии операций. Это некоторые процедуры с одинаковыми именами, но разным типом и числом (ограниченным, т.е. псевдобесконечного списка параметров написать нельзя) параметров. В Аде таким образом реализована процедура PUT( ,…).
В С++ богатый набора встроенных операций, которые можно переопределять (в том числе и стандартные знаки операций).
Надёжная библиотека ввода-вывода:
iostream.h
(“<<”, “>>” операции сдвига вправо, влево)
![]() |
<< e - вывод объекта в канал
ch >> obj - операция ввода из канала
Есть ТД iostream (поток ввода-вывода). Каждая операция возвращает:
iostream& operator << (iostream& int, T x);
iostream& operator >> (iostream& int, T x);
И для каждого ТД, для которого хотим определить ввод-вывод, переопределяем эти операции (как правило, на базе введённых в iostream ТД). А возвращаемое значение – это ссылка на первый аргумент. Выполняется слево-направо. Получается мощная и красивая схема:
cout << “count = “ << cnt << ‘.’ <<endl;
Надёжно и читабельно. Также существуют объекты специального типа, которые служат модификаторами ввода-вывода, они позволяют управлять форматом, например, endl, модификаторы, позволяющие изменять ширину поля и т.д.. Вся мощь библиотеки stdio.h сохранена и никаких проблем с надёжностью нет. Но это достигнуто за счёт переопределения стандартных знаков операций, и это наглядно. Но язык усложняется, за счёт введения достаточно нетривиальных вещей.
Альтернативный подход: концепция динамической идентификации типов в C#. Наблюдение за процедурами с переменным числом параметров показывает, что есть некий список параметров, который мы можем проинициализировать. А макросы va_start, va_end позволяют нам итерировать по этому списку. Проблема Си/С++ в том, что мы не знаем, что же на самом деле подставляется в эти параметры и из-за этого соответствующая ненадёжность.
Если у нас язык таков, что Object – базовый ТД, из которого происходят элементы всех классов (все объекты), к нему можно свести любой тип, с помощью механизма обёрток (boxing). В Delphi - это TObject, в Java – Object, а в C# - object. К тому же в этих ЯП поддерживается динамическая идентификация типа (проверка является ли этот объект объектом данного типа, иначе генерируется исключение).
ТД ParamArray – представляет переменный список параметров в качестве массива, каждый из элементов которого - объект, типа Object, который на самом деле есть ссылка на объект класса или обёртка (псевдообъект) структуры или простого значения. У обрабатывающей процедуры последний параметр ParamArray и она работает с ним как с массивом, и вывод осуществляется в зависимости от того, что за элементы в нём. Тут нет никакого статического контроля, но есть динамический. Но кто упрятывает параметры в обёртки. Если мы делаем это сами, то это можно реализовать на любом ЯП самим описав ТД ParamArray и т.д.. Это неудобно и мы теряем все преимущества переменного списка параметров. Очевидно, что это должен делать не программист. В C# это встроенный ТД (ParamArray).
Общее правило - компилятор ищет точное соответствие:
void f(T1 x);
void f(T1 x, T2 x);
void f(T1 x, ParamArray objc);
T1 a, T2 b, T1 c, T3 d
f(a);
f(a, b);
f(a, d);
Пусть T1, T2 и T3 никак не связаны (С наследованием чуть сложнее). Следовательно, точное соответствие не было найдено и он выбирает третий вариант функции, автоматически формируя массив из одного элемента.
f(c, a, b, d);
В данном случае формируется массив из трёх элементов. Таким образом построена библиотека ввода-вывода (Паскаль: WriteLn(форматная строка, ParamArray);), с контролем типов объектов.
В Java более простые правила. Ввод-вывод строк: у каждого объекта (он происходит от ТД Object) есть метод toString, который и применяется по необходимости (компилятор смотрит на контекст):
String s;
int i;
s+i;
Где ”+” – операция конкатенации для строк. i завертывается в оболочку класса Int (который происходит от ТД Object), вызывается метод toString и к строке s добавляется символьное представление i. Следовательно, получаем одну процедуру вывода: вывод строки, а её аргумент – это совокупность объектов, для которых вызывается метод toString. Для нового класса переопределяем метод toString. Похоже на прошлый подход.
ParamArray и прочее в C# появилось, чтобы программисты с С++ легче переходили на С#, а во-вторых в некоторых случаях (например, когда мы пишем процедуры обработчики событий) у нас приходит переменный список параметров и априори мы не можем предсказать какой он будет. Но оно надёжно за счёт динамической идентификации типов. В С++ это тоже есть, но там нет того, что каждый объект происходит от общего и поэтому там ограниченные возможности подобного рода решений.
Глава 5
Определение новых ТД
П.1. Концепция уникальности ТД (Основные проблемы)
Более точная формулировка понятий, строгая и сильная типизация. Их постоянно мешают и непонятно, то ли Си со строгой, то ли с сильной типизацией. Мы определим концепцию уникальности типов, и язык имеет тем боле сильную или строгую типизацию, чем он ближе к этой концепции. Лучше всех удовлетворяет язык Ада. 4 пункта:
1)Каждый объект данных имеет ТД, и этот тип единственен и статически определим.
Типы данных это как бы классы эквивалентности, на которые разбиваются все объекты. То есть не может быть ни константы, ни переменной без ТД. Говоря «статически определим» имеем в виду, что, глядя на текст программы, мы всегда можем определить к какому ТД он принадлежит (Паскаль: по виду константы определяли её тип), иначе – сообщение об ошибке. В Аде у нас есть уточнитель типа данных (например, когда константа принадлежит к нескольким перечислимым ТД): T’e и компилятор всегда знает всё о ТД. Такая концепция во всех традиционных ЯП, и именно из-за этого они с одной стороны являются достаточно эффективными (компилятор может оптимизировать). Но недостатком такого подхода является то, что все ТД разбиваются на непересекающиеся классы, и теряется гибкость.
2)Типы эквивалентны тогда и только тогда, когда их имена совпадают.
Это именная эквивалентность типов, все традиционные современные ЯП поддерживают её (с небольшими отклонениями). Существует ещё и структурная эквивалентность (в первых ЯП, когда типизации уделяли мало внимания речь шла именно о ней – PL|I): ТД совпадают, если их структуры совпадают. От структурной эквивалентности отказались (так как при нём тип – это набор значений), сейчас главное в типе – набор операций (даже на основе одной и той же структуры). Так же, если мы рассмотрим сложные ЯП, структурная эквивалентность двух рекурсивных типов может быть алгоритмически неразрешима. Но иногда отступают от именной эквивалентности: в большинстве старых ЯП есть синонимия типов, например, в Паскале:
Type T = integer;
Типы T и integer теперь синонимы. Данные одного типа могут появляться везде, где появляются данные другого типа и наоборот. В Си и C++ то же (typedef). Здесь типы эквивалентны, если имена совпадают, либо они синонимичны. Это было введено из соображений лучшей документируемости. С точки зрения надёжности, такая конструкция даёт мало. Например, пусть у нас есть три типа данный, синонима integer: Length, Width, Square. Но мы можем к площади прибавить ширину или длине присвоить отрицательное значение.
В Аде (производные типы, введём с помощью специальной концепции нового ТД, который наследует всё множество значений и всё множество операций, эти величины):
type Length is new Integer;
Width is new Integer;
Square is new Integer;
Это совершенно разные типы и их нельзя смешивать.
3)Каждый ТД имеет свой набор операций с фиксированным набором параметров.
Имеется в виду, что фиксированы их число и типы. Переменный список параметров – отклонение от этой концепции.
4)Неэквивалентные ТД несовместимы по операциям.
Тип данных int имеет один набор операций (+, -, *). В Аде у типов Length, Width и Square одни и те же операции, но так как они не эквивалентны из-за разных имён, то их смешивать нельзя. Но мы можем переопределить операцию *:
Length*Width=Square
И как следствие, получить более надёжную программу. Если грамотно определить систему типов, то многие ошибки будут найдены ещё на стадии компиляции.
ЯП, полностью поддерживающий 1)-4) – надёжный. Эта концепция по языку Ада, а другие ЯП ослабляют свойства этой типизации и менее надёжны. В Аде нет синонимии типов, но мы можем переопределять операции, также полностью запрещено неявное (вставляемое компилятором) приведение типов (нарушение 4)):
v:=e
Типы v и e должны совпадать. А компилятор может вставлять или не вставлять контроль. Но между подтипами неявное преобразование возможно, но оно может сопровождаться контролем:
A: Tarr(1..10);
B: Tarr(2..11);
A := B;
Присвоение корректно, так как совпадают динамические атрибуты A’Length и B’Length. Если компилятор не может определить их длины (А и В – формальные параметры) он там вставляет проверку на динамический атрибут «длина». В других ЯП неявные преобразования, которые ухудшают надёжность.
Иногда нарушается именная эквивалентность типов. В Си неявно пролезает структурная эквивалентность типов, если используем одинаковые структурные ТД в разных модулях:
![]() |
m1.c struct S { int a; int b; }
void f(struct S x);
m2.c struct S1 { int x; int y; }
extern void f(struct S1 y);
…
struct S1 a;
f(a);
Но хороший программист не будет описывать ТД в файле.с, кроме как для локального использования. Иначе описывает в отдельном файле.
В С++ эта проблема решена за счёт кодирования имён. Страуструп предложил приём: для каждой внешней процедуры f вместо соответствующего имени, которое дал пользователь, на самом деле ей присваивается закодированное имя, которое кодирует и имена параметров и возвращаемый тип функции, возможно, не имеющее никакой лексической нагрузки, что-то вроде f@#_…, но гарантируется уникальность индификатора. И в данном примере ошибка будет найдена, не на стадии трансляции (так как она раздельная и независимая), а на этапе сборки программы.
Концепция типов ориентирована на надёжность и эффективность. Эффективность концепции в том, что поведение ТД определяется строго операциями (и, как следствие, контроль и оптимизация компилятором), но традиционные ЯП страдают с точки зрения гибкости.
Тут возникают две проблемы:
1)Множественность ролей с точки зрения ТД (Янус-проблема).
Тип характеризует содержательную роль объекта ЯП, но этих содержательных ролей может быть много, а мы вынуждены одну из них выделять как главную и использовать её как единственную роль. Традиционные ЯП не знают адекватных средств решения проблемы. Единственная возможность – использование объединений:
T1
.
union T .
.
TN
Но это тоже не адекватное средство как с точки зрения надёжности, так и с точки зрения удобства программирования.
Адекватное решение только в ООЯП.
2)Полиморфизм (Множественность форм).
Тесно связано с 1) – это множественность форм с точки зрения набора операций. Одной операции может соответствует, вообще говоря, множество форм. Например, операция сложения «+»: целое сложение, вещественное сложение, конкатенация двух строк, сложение двух векторов или двух матриц. Эта проблема разрешается с помощью статического перекрытия операций. Или метод Draw(); у объекта, но своя форма для каждого ТД (вспомним курс машинной графики). Есть: статически полиморфные операции (на этапе трансляции знаем множество ТД и для каждого ТД известно какую форму приобретает операция «+») статически полиморфные языки в некотором смысле средство решения этой проблемы, но в некоторых случаях не можем предвидеть, какого типа будет объект. Например, графическая библиотека использует зафиксированный на этапе трансляции набор объектов. Компилятор выберет нужный Draw();. Если мы хотим добавить в библиотеку новый объект, то мы должны добавить новый ТД, видимо, с помощью соответствующего объединения и изменить обращение к процедуре Draw(); во всей библиотеке, следовательно, серьёзные изменения в исходном коде и перетрансляция всей библиотеки. В традиционных ЯП только статический полиморфизм, основанный на понятии объединения и перекрытия имён во время трансляции. По настоящему решается в ООЯП, где есть динамическое связывание методов (виртуальный метод С++, все методы в Java и C#).
П.2. Логический модуль
ТД = множество операций + множество значений
С современной точки зрения ТД – это прежде всего множество операций. Множество операций – это набор процедур и функций (играет основополагающую роль), а множество значений – это структура данных или совокупность структур данных (например на основе базисных ТД или понятия наследования). Следовательно, для определения ТД, мы должны ввести нобор структур данных типа и множество операций, присвоив этому одно имя.
Новые ТД можно определять и на Паскале и на Си, но там нет конструкторов, подчёркивающих дуализм.
С этой точки зрения ЯП разделяются на:
Модульные языки: вводится понятие контейнера, объединяющего множество операций и множество значений. Модуль не является ТД. Модульные языки: Ада, Модула 2, Оберон.
Языки с классами: класс – это сущность, содержащая данные и обладающая набором операций. Класс как бы является универсальной обёрткой, в отличие от модуля, который является более общей конструкцией, и его основное предназначение - определять ТД. Но и может использоваться как модуль. В языки с классами понятие модуля пролезает, но оно уже несколько отлично: это понятие пакета и пространства имён. Языки с классами: C++, Java, C#.
Delphi похож на Модулу 2 (по модульной структуре), но и является языком с классами, в этом проявляется его двойственная природа (он был создан на основе Турбо Паскаля, но в него были добавлены и другие концепции).
Модульные ЯП.
Есть понятие unit. В Delphi:
unit имя;
interface
[набор объявлений]
implementation
реализация
end имя.
Доступно другим модулям с помощью конструкции uses. Это набор объявлений типов, переменных и процедур. Все такие процедуры должны быть реализованы в implementation.. Каждый модуль представляет сервис – набор услуг, описанных в интерфейсе. А вспомогательные процедуры и ТД - описываются в реализации. Модуль может описывать 0 (набор констант), 1 или несколько ТД. Модуль более общее понятие, чем ТД, но, в частности, может служить для описания ТД.
Надо иметь сервис для доступа к этому модулю. У нас есть клиентские модули, которые используют сервис и сервисные модули, которые его предоставляют. Все клиентские модули используют конструкцию: uses список имён модулей. Это значит, что все имена, описанные в интерфейсной части становятся непосредственно видимыми в клиентском модуле. Два базовых понятия, связанных с понятием логического модуля: unit и пространство имён.
Модули структурируют пространство имён:
![]() |
M1 M2 M3 …
Имена: описанные в интерфейсе и реализационные. Глобальное пространство имён состоит из имён описанных в интерфейсе. Но непосредственно видимыми (мы их можем употреблять без каких-то пояснений) являются имена модулей. В Delphi программи состоит из набора юнитов и хменанепосредственно видимы. Конструкция uses, .которую мы можем употреблять либо в начале интерфейсной части, либо в начале реализационной, делает все имена из соответствующего модуля непосредственно видимыми. Но имена могут конфликтовать. И помнить все имена из всех модулей практически невозможно. Поэтому говорим,,чоменавдмытолько потенциально. Для решения этой проблемы в ЯП было введено понятие уточнения (квалификации) имени – это конструкция, указывающая к какому модулю относится имя.
Qualifying в Delphi: имя_модуля.имя. Имена модулей должны быть разные. Как следствие, на Delphi супербольших проектов не пишут.
Есть клиентские и сервисные модули. Клиентские являются и сервисными, за исключением главной программы., которая является только клиентским модулем и её вид несколько иной:
![]() |
program имя;
uses …;
объявления
begin
…
end имя.
В Модуле 2 сделано гибче. В Delphi одна физическая единица unit (один файл) разбивается на две логические части, а тут и интерфейс и реализация – это отдельные модули (файлы):
![]() |
DEFINITION MODULE имя;
набор объявлений
END имя.
Для него может существовать модуль реализаций (должны быть реализованы все процедуры из модуля объявлений и тут могут быть вспомагательные ТД и процедуры):
![]() |
IMPLEMENTATION MODULE имя;
реализация
END имя.
Есть три вещи: определение (или интерфейсная часть), реализация и использование (сокращённо - OPU). В традиционных ЯП эти три вещи предельно физически разделены:
stacks.MOD - реализация
stacks.DEF - определение
import - использование
Модула 2:
1) IMPORT список имён модулей
В Delphi надо было уточнать только конфликтующие имена. А в Модуле 2 все эти имена становятся потенциально видимыми и их надо уточнять.
IMPORT InOut;
…
InOut.Writeln - и так с любыми именами из модуля InOut.
Иначе компилятор скажет, что не знает имени Writeln.
Есть альтернативная форма:
2) FROM имя модуля IMPORT список имён
Эти имена становятся непосредственно видимыми:
FROM InOut IMPORT writeln, writestring;
…
writeln;
writestring(s);
При конфликте имён надо их квалифицировать.
Это разделение в Модуле 2 сделано для удобства программистов: мы пишём один раз, а читаем много. Все стандартные имена описаны по крайней мере в help, но имена из модулей сторонних поставщиков нигде не описаны. Когда у нас есть модуль описаний, намного удобнее читать, потому, что если описание перемешано с реализацией, нам приходится пролистывать огромные тексты процедур. Именно по этой причине Borland поставлял все версии Паскаля с утилитой типа grep, которая как раз и позволяла выбирать интересующие нас вещи. В Модуле 2 имя – либо стандартное (их мало так как описание языка всего 40 страниц), либо описано в этом модуле, либо опиисано в конструкции FROM.
Оберон: Запрещена конструкция FROM…
IMPORT список имён модулей
И все имена из других модулей надо уточнять: тяжело писать, но легко читать.
Все эти понятия модульности сформировались ещё в 70-е годы.
Разделение OPU – это хорошо и содержательно, так как программист не обращает внимания на реализацию и её детали, а на определение и то, что ему нужно от каждого конкретного сервиса. Но современные ЯП отходят от разделения O и P. OP и U разделены – это аксиома. Но O и P в современных ЯП слиты воедино.
Оберон (наследник Модулы):
MODULE имя;
IMPORT
…
объявления
begin
операторы
end имя.
Просто в Обероне все имена которые попадают в интерфейсную часть должны сопровождаться *.
Пример:
MODULE Stacks;
TYPE Stack *=
record
…
end;
procedure Push*(var S:Stack; X:INTEGER);
END Stacks.
Это неудобно: описание и тела процедур в одном месте, а нас могут интересовать всего лишь параметры. Но тот же подход в Java и C#, то есть там тоже слиты воедино O и P (В С++ могут быть разделены, а здесть требуется, что они должны быть объединены). А концепция, чтобы ничего не было потеряно с точки зрения удобства пользователя, здесь не пострадала, так как эти ЯП уже часть системы программирования.
Оберон: Тут есть простейшая утилита, автоматически генерирующая интерфейс (так называемый псевдомодуль), расписывающий все публичные имена , являющиеся частью системы программирования:
Информация в лекции "9 Оператор присваивания" поможет Вам.
DEFINITION имя;
type stack = …;
procedure push(…);
…
END имя;
В Java есть утилита Javadoc, генерирующая интерфейсы класов (плюс вставляет ссылки на документации классов из других модулей, на классы которых ссылаюся параметры данных классов).