Michalis Kamburelis
В мире существует множество книг о Паскале, однако, многие из них говорят об "устаревшем" Паскале, без классов, модулей, generic-ов (дженериков) и многих других современных методов и приёмов программирования.
С целью заполнения этой бреши и было написано это краткое введение о том, что часто называется современным объектным Паскалем. Чаще его называют просто "Паскаль" или "Наш Паскаль". Впрочем, представляя язык, важно подчеркнуть, что это современный, объектно-ориентированный язык, который был существенно усовершенствован по сравнению со старым (Turbo) Pascal, который когда-то давно изучали в школах и институтах. Сегодня его вполне можно сравнивать с такими известными языками программирования, как C++, Java или C#.
-
Паскаль имеет все современные особенности, которые можно ожидать -- классы, модульную структуру, интерфейсы, generic-и…
-
Паскаль компилируется в быстрый машинный код.
-
Паскаль является типобезопасным языком, что в некоторых случаях существенно упрощает отладку.
-
В основном Паскаль - высокоуровневый язызк, однако он имеет возможность использовать низкоуровневые подходы если это необходимо.
Он так же имеет превосходный кроссплатформенный портативный компилятор с открытым исходным кодом: Free Pascal Compiler (http://freepascal.org/). Для него существует несколько IDE (включающих в себя редактор, отладчик, библиотеки компонентов, дизайнер форм), одна из наиболее известных называется Lazarus (http://lazarus.freepascal.org/). Автора данной книги является создателем Castle Game Engine (https://castle-engine.io/) - который является мощным портативным двух- и трёхмерным игровым движком, использующим этот язык, для создания игр под многие платформы: Windows, Linux, MacOSX, Android, iOS, web плагины.
Данное краткое введение в первую очередь ориентировано на программистов, которые уже имеют некоторый опыт в других языках программирования. Здесь не будет раскрываться значение некоторых универсальных концепций, таких как "что такое класс", лишь будет показано как они реализуются в современном Паскале.
Прим.перев. Здесь и далее мы будем использовать оригинальные английские понятия такие как unit, constructor, override, reference-counting в случаях, если они являются ключевыми словами языка либо не имеют общепринятых переводов на русский язык. Также часто предпочитается использовать оригинальное английское слово его транслитерации, как в случае с понятием "generic" выше.
По доброй традиции знакомство с языком начинают с самой базовой и простой программы "Hello world". На Паскале она выглядит следующим образом:
link:code-samples_russian/hello_world.lpr[role=include]
Это — полноценная программа, которую можно скомпилировать и запустить.
-
Это можно сделать с помощью командной строки FPC, просто создав новый файл
myprogram.lpr
с кодом программы внутри и выполнив командуfpc myprogram.lpr
. -
Если используется Lazarus, то необходимо создать новый проект (в строке меню: Project → New Project → Simple Program). Сохраните его как
myprogram
и скопируйте в него этот исходный код. Компиляция выполняется используя пункт Run → Compile в меню. -
Данная программа является консольной, то есть, в обоих случаях скомпилированный исполняемый файл нужно будет запустить из терминала командной строки, чтобы увидеть результат.
Остальная часть этой книги рассказывает о самом объектном Паскале, поэтому не ожидайте чего-либо большего, чем консольных приложений. Если хочется взглянуть на что-либо более "крутое", можно просто создать новый GUI проект в Lazarus (Project → New Project → Application). Вуаля! — рабочее кроссплатформенное GUI приложение, с нативным видом, использующее удобные визуальные библиотеки. Lazarus и Free Pascal Compiler имеют множество готовых компонент для сетей, GUI, баз данных, чтения и записи различных форматов файлов (XML, json, изображения…), управления потоками и всем, что только может понадобиться программисту. Ярким тому примером является Castle Game Engine, о котором упоминалось ранее :)
link:code-samples_russian/functions_primitives.lpr[role=include]
Чтобы задать возвращаемое значение функции, необходимо присвоить какое-либо значение "волшебной" переменной Result
в процессе выполнения функции. Её можно свободно читать и устанавливать новое значение так же просто как и любую локальную переменную.
function MyFunction(const S: string): string;
begin
Result := S + ' Добавим что-нибудь';
Result := Result + ' и ещё что-нибудь!';
Result := Result + ' И ещё немножко!';
end;
Можно рассматривать имя функции (MyFunction
в примере выше) как переменную, которую можно использовать как самую обычную переменную. Но лично я бы не советовал так делать, поскольку это выглядит не очень "прозрачно", особенно в случае, если это значение используется с правой стороны выражения. Лучше всегда использовать Result
, в случае, если необходимо использовать или устанавливать возвращаемое значение функции.
Естественно, при необходимости можно вызывать функцию рекурсивно. Но при этом в случае, если вызывается беспараметрическая функция, следует не забывать указать пустые скобки ()
(которые в обычных случаях в Паскале можно опускать) - иначе будет просто выполнен доступ к результату текущей функции. Например:
function ReadIntegersUntilZero: string;
var
I: Integer;
begin
Readln(I);
Result := IntToStr(I);
if I <> 0 then
Result := Result + ' ' + ReadIntegersUntilZero();
end;
Функция Exit
служит для завершения выполнения процедуры или функции до того, как она достигнет завершающего end;
. Если Exit
вызвать без параметров, она вернёт последнее значение, присвоенное Result
. Так же можно использовать конструкцию Exit(X)
, чтобы установить результат функции и завершить её исполнение немедленно — это эквивалентно конструкции return X
в С-подобных языках.
function AddName(const ExistingNames, NewName: string): string;
begin
if ExistingNames = '' then
Exit(NewName);
Result := ExistingNames + ', ' + NewName;
end;
Обратите внимание, что результат функции может быть "проигнорирован" (отброшен). Любую функцию можно вызвать как обычную процедуру. Делать так имеет смысл если функция выполняет некоторые побочные операции (например, изменяет глобальные переменные), а не просто вычисляет некоторый результат. Например:
var
Count: Integer;
MyCount: Integer;
function CountMe: Integer;
begin
Inc(Count);
Result := Count;
end;
begin
Count := 10;
CountMe; // результат функции будет отброшен, однако функция выполняется, Count станет равен 11.
MyCount := CountMe; // запоминаем результат выполнения функции будет, Count теперь 12.
end.
Конструкции if .. then
или if .. then .. else
запускают определённый код, когда выполняется указанное условие. В отличии от C-подобных языков, в Паскале нет строгого требования ставить условие в скобки.
var
A: Integer;
B: boolean;
begin
if A > 0 then
DoSomething;
if A > 0 then
begin
DoSomething;
AndDoSomethingMore;
end;
if A > 10 then
DoSomething
else
DoSomethingElse;
// идентично предыдущему примеру
B := A > 10;
if B then
DoSomething
else
DoSomethingElse;
end;
Оператор else
всегда относится только к последнему условию if
. Поэтому можно вполне рассчитывать на однозначность выполнения такого кода:
if A <> 0 then
if B <> 0 then
AIsNonzeroAndBToo
else
AIsNonzeroButBIsZero;
Впрочем, заключение вложенного if
внутри блока begin … end;
является лучшим вариантом, чем предыдущий пример, поскольку он более читабельный, даже если нарушены отступы или большой объём кода и комментариев затрудняет понимание. Таким образом, всегда очевидно к какому if
относится данный else
- относительно A или относительно B - и, соответственно, куда меньше шансов допустить ошибку при написании кода или при его правках.
if A <> 0 then
begin
if B <> 0 then
AIsNonzeroAndBToo
else
AIsNonzeroButBIsZero;
end;
Логические операторы представлены в Паскале операторами and
, or
, not
, xor
. Их значение в большинстве случаев очевидно для людей, знакомых с компьютерной грамотой. Разве что, за исключением оператора xor
, который в русской литературе обычно называется "исключающее или". Эти операторы принимают булевские аргументы (boolean), и возвращаемое значение имеет тот же тип boolean. Они также могут работать как побитовые операторы, в случае, если оба аргумента целого типа (integer, byte или подобные), в этом случае они также возвращают значение идентичного целого типа.
Операторы отношения (сравнения) - представлены следующими комбинациями символов: =
, <>
, >
, <
, <=
, >=
, значение которых вполне очевидно. Следует отметить, что в отличии от синтаксиса С-подобных языков, в Паскале оператор сравнения выглядит как один знак "равно" A = B
(в отличии от С, где используется код A == B
). Специальным оператором присваивания в Паскале является :=
.
Логический (или побитовый) оператор имеет более высокий приоритет, чем операторы отношения. Поэтому, может понадобиться использовать круглые скобки вокруг сравниваемых выражений для указания правильного порядка выполнения операторов.
Следующий пример вызовет ошибку компиляции:
var
A, B: Integer;
begin
if A = 0 and B <> 0 then ... // так делать НЕЛЬЗЯ
Ошибка связана с тем, что компилятор в первую очередь пытается выполнить побитовый оператор and
в середине выражения. В результате получается (0 and B)
- побитная операция, возвращающая целочисленную величину. Далее компилятор выполняет оператор "равно" и получает булевскую величину A = (0 and B)
. И в конце концов появляется ошибка type mismatch, в результате попытки сравнения булевской величины A = (0 and B)
с целочисленной величиной 0
.
Правильно записывать это условие в следующем виде:
var
A, B: Integer;
begin
if (A = 0) and (B <> 0) then ...
Паскаль использует "короткую оценку (short-circuit evaluation)" - мощную оптимизацию, позволяющую не вычислять выражение целиком, если какая-либо его часть полностью определяет результат. Рассмотрим пример:
if MyFunction(X) and MyOtherFunction(Y) then...
-
Значение функции
MyFunction(X)
всегда рассчитывается первым. -
И если
MyFunction(X)
вернёт значениеfalse
, это означает, что мы уже знаем результат всего выражения - какое бы ни было второе значение, при выполненииfalse and что-нибудь
мы всегда получимfalse
. Таким образомMyOtherFunction(Y)
вообще не будет выполняться. -
Идентичная ситуация и с выражением
or
. В данном случае, если мы наперёд знаем, что результат будетtrue
потому что первый аргумент имеет значениеtrue
, второй аргумент не влияет на результат и не будет рассчитываться. -
Это особенно полезно если нужно записать выражение типа:
if (A <> nil) and A.IsValid then...
В данном случае не возникнет ошибки даже в случае если
A
имеет значениеnil
. Ключевое словоnil
это указатель, который в численном представлении указывает на "нулевой" адрес. Во многих языках программирования он называется нулевым указателем (null pointer).
Если в зависимости от разных значений определённого выражения должны быть выполнены разные действия, тогда может оказаться удобной конструкция case .. of .. end
.
case SomeValue of
0: DoSomething;
1: DoSomethingElse;
2: begin
IfItsTwoThenDoThis;
AndAlsoDoThis;
end;
3..10: DoSomethingInCaseItsInThisRange;
11, 21, 31: AndDoSomethingForTheseSpecialValues;
else DoSomethingInCaseOfUnexpectedValue;
end;
Условие else
опционально (и соответствует default
в C-подобных языках). В случае, если текущее значение анализируемого выражения не совпадает ни с одним из описанных случаев и нет условия else
, то программа просто пропустит всю конструкцию case
и будет выполняться далее.
Программисты С-подобных языков могут сравнить case
с весьма подобной конструкцией switch
в этих языках. Стоит отметить, что case
в Паскале защищён от случайного выполнения следующей инструкции, т.е. нет необходимости уделять внимание тому, чтобы размещать инструкцию break
в конце каждого блока. После выполнения ветви условия программа автоматически закончит обработку конструкции case
и продолжит работу далее.
Перечисляемый тип (enumerated) в Паскале является очень удобным и прозрачным. Возможно, Вам он понравится и Вы будете использовать его чаще чем перечисляемые типы в других языках :)
type
TAnimalKind = (akDuck, akCat, akDog);
Общепринято, что префикс перечисляемого типа состоит из двух букв сокращения имени типа, следовательно ak
= сокращение для "Animal Kind". Это полезное соглашение, так как имена перечисляемых типов находятся в глобальном пространстве переменных unit-а. Таким образом, с помощью префикса ak
автоматически уменьшаются шансы на случайный конфликт с другими идентификаторами.
Note
|
Конфликты в именах не приводят к неработоспособности программы. Вполне допустимо в различных unit-ах определять одинаковые идентификаторы. Однако, желательно избегать подобных конфликтов везде, где это возможно, чтобы код был прост для понимания и анализа. |
Note
|
Можно избежать попадания имён перечисляемых типов в глобальное пространство с помощью директивы компилятора {$scopedenums on} . В таком варианте будет необходимо обращаться к ним через имя типа следующим образом: TAnimalKind.akDuck . В результате отпадает необходимость в префиксе ak , и можно просто оставить исходные названия Duck, Cat, Dog . Такое исполнение подобно тому, как работают перечисляемые списки в C#.
|
Прозрачность перечисляемого типа означает, что он не совместим напрямую с целочисленными величинами. Тем не менее, если такая совместимость необходима, можно использовать Ord(MyAnimalKind)
, чтобы вручную привести список к целочисленному типу. Обратная операция будет выглядеть как приведение типа TAnimalKind(MyInteger)
и превратит целое число в соответствующий перечисляемый тип. В последнем случае необходимо также быть уверенным, что MyInteger
является частью диапазона от 0
до Ord(High(TAnimalKind)))
.
Перечисляемые типы могут быть также использованы в качестве индексов массивов фиксированной длины:
type
TArrayOfTenStrings = array [0..9] of string;
TArrayOfTenStrings1Based = array [1..10] of string;
TMyNumber = 0..9;
TAlsoArrayOfTenStrings = array [TMyNumber] of string;
TAnimalKind = (akDuck, akCat, akDog);
TAnimalNames = array [TAnimalKind] of string;
Они также могут использоваться для создания наборов (они же set-ы, они же внутренние битовые поля):
type
TAnimalKind = (akDuck, akCat, akDog);
TAnimals = set of TAnimalKind;
var
A: TAnimals;
begin
A := [];
A := [akDuck, akCat];
A := A + [akDog];
A := A * [akCat, akDog];
Include(A, akDuck);
Exclude(A, akDuck);
end;
link:code-samples_russian/loops.lpr[role=include]
Примечания:
-
Может показаться, что различие между циклами
while
иrepeat
лишь "косметические" с единственным отличием, что условие записано "с точностью до наоборот": в случаеwhile .. do
выполнение продолжается, пока условие истинно, а приrepeat .. until
- выполнение прекращается, когда условие истинно. Впрочем, есть ещё одно важное отличие: в случаеrepeat
, условие проверяется не в начале, а в конце цикла. Поэтому содержимое циклаrepeat
всегда выполняется как минимум один раз. -
Конструкция
for I := .. to .. do …
похожа на C-подобный циклfor
. Тем не менее, она более ограничена, поскольку невозможно указать произвольное действие/условие чтобы контролировать итерации цикла. В Паскалеfor
используется строго для итерации через последовательные числа (или другие порядковые типы). Единственной уступкой является возможность использованияdownto
вместоto
, чтобы производить счёт в обратном порядке.С другой стороны, это существенно облегчает понимание кода, и лучше для оптимизации в его исполнении. Например, значения верхней и нижней границы вычисляются лишь один раз, до начала исполнения цикла.
Следует также обратить внимание, что переменная, которая использовалась для цикла (в примере выше -
I
) становится неопределённой после окончания цикла кроме случая досрочного выхода из цикла с помощью командBreak
илиExit
. Циклfor I in .. do ..
такой же как конструкция foreach в многих других языках и хорошо понимает организацию всех встроенных типов: -
Он может перебирать все значения массива (см. пример выше).
-
Он может перебирать все возможные значения перечисляемого типа:
var AK: TAnimalKind; begin for AK in TAnimalKind do...
-
Он может перебирать все элементы набора:
var Animals: TAnimals; AK: TAnimalKind; begin Animals := [akDog, akCat]; for AK in Animals do ...
-
И так же работает на всех пользовательских типах, включая generic-и, например,
TObjectList
илиTFPGObjectList
.link:code-samples_russian/for_in_list.lpr[role=include]
Мы ещё не рассматривали концепцию классов, поэтому последний пример может показаться неочевидным. Но мы обязательно рассмотрим этот вопрос чуть позже :)
-
Для простого вывода строки в Паскале используется процедура Write
или WriteLn
. Во втором случае в конце автоматически добавляется символ переноса строки.
Это "волшебная" процедура в Паскале, Она принимает переменное число аргументов, которые могут иметь практически любой тип. Они все будут автоматически сконвертированы в строчный тип при выводе. Кроме того, можно добавить специальный синтаксис для указания каким образом отформатировать число.
WriteLn('Hello world!');
WriteLn('Можно вывести целое число: ', 3 * 4);
WriteLn('Отформатировать его: ', 666:10);
WriteLn('А также вывести число с плавающей запятой: ', Pi:1:4);
Чтобы явным образом добавить перенос строки можно использовать константу LineEnding
(из библиотеки FPC RTL). (Castle Game Engine имеет также более краткий вариант NL
). В отличии от HTML и других подобных синтаксисов разметок в Паскале обратная косая (\
) не позволяет вставлять специальные символы в строке, например:
WriteLn('Первая строка.\nВторая стока.'); // НЕВЕРНЫЙ пример
не вставит перенос строки, а просто выдаст все символы этой строки в одной строчке. Правильно делать следующим образом:
WriteLn('Первая строка.' + LineEnding + 'Вторая строка.');
или так:
WriteLn('Первая строка.');
WriteLn('Вторая строка.');
Стоит отметить, что функции Write
/WriteLn
будут работать только в консольных приложениях. Для этого необходимо указывать {$apptype CONSOLE}
(но не {$apptype GUI}
) в главном файле программы. На некоторых ОС консоль явно или скрыто присутствует всегда (Unix) и эта директива не используется. А в некоторых системах попытка выполнения Write
/WriteLn
из GUI приложения приведёт к ошибке (например, в Windows).
В Castle Game Engine не советуется использовать WriteLn
, поскольку для этого есть специальная функция WriteLnLog
или WriteLnWarning
для вывода логов и отладочной информации. Их результат всегда будет направлен в полезном направлении: для Unix-подобных систем это будет стандартный вывод в консоль. Для Windows GUI приложений это будет лог-файл. В Android вывод будет направлен в Android logging facility (инструмент логов Андроида), который можно просматривать с помощью команды adb logcat
. Использовать WriteLn
есть смысл лишь в ограниченном наборе случаев, например, для консольных приложений (исполняемых из командной строки) в которых можно быть точно уверенным, что стандартный вывод определён. Например, так можно делать в конвертере или генераторе трёхмерных моделей, который предназначен для запуска из командной строки.
Для преобразования произвольного количества аргументов в строку (вместо того, чтобы напрямую выводить их в терминал консоли) есть несколько возможных подходов.
-
Некоторые конкретные типы можно преобразовать в строку используя специальные функции, такие как
IntToStr
иFloatToStr
. В дальнейшем суммировать (concatenation) строки в Паскале можно просто используя знак сложения. Таким образом можно создавать составные строки:'Моё целое число ' + IntToStr(MyInt) + ', а значение числа пи составляет ' + FloatToStr(Pi)
.-
Преимущество: это очень удобно. Существует множество готовых функций типа
XxxToStr
и им подобных (например,FormatFloat
), для множества различных типов данных. -
Второе преимущество: они почти всегда имеют обратную функцию. Чтобы преобразовать строку (например, введённую пользователем) в целое или дробное число можно использовать
StrToInt
,StrToFloat
и подобные (например,StrToIntDef
). -
Недостаток: длинная сумма множества
XxxToStr
и строк выглядит некрасиво.
-
-
Функция
Format
используется в видеFormat('%d %f %s', [MyInt, MyFloat, MyString])
. Она подобна функцииsprintf
в C-подобных языках. Она вставляет аргументы в соответствующие placeholder-ы согласно заданному образцу. Эти placeholder-ы могут использовать специальный синтаксис, влияющий на форматирование, например%.4f
это дробный формат с 4 знаками после запятой.-
Преимущество: отделение строки от аргументов выглядит чисто и красиво. Легко изменить текст строки, не изменяя аргументов, например, если необходимо выполнить перевод.
-
Второе преимущество: не используются "волшебные" свойства компилятора. Можно использовать идентичный синтаксис для передачи произвольного количества аргументов произвольного типа в пользовательских процедурах (для этого определите принимаемый параметр как
array of const
). Затем можно передать эти аргументы функцииFormat
, или разобрать на список параметров и делать с ним всё, что угодно. -
Недостаток: компилятор не проверяет, соответствует ли строка-образец аргументам. Используя неверный тип placeholder-а приведёт к ошибке
EConvertError
, впрочем, гораздо более понятной, чем segmentation fault (наиболее часто SIGSEGV).
-
-
В Паскале также существует функция
WriteStr(TargetString, …)
во многом подобна базовой функцииWrite(…)
, с одним отличием - результат сохраняется вTargetString
.-
Преимущество: эта функция имеет все возможности функции
Write
, в том числе и специальный "волшебный" синтаксис для форматирования, как напримерPi:1:4
. -
Недостаток: такой специальный синтаксис для форматирования является "волшебным", т.е. он написан специально для конкретной процедуры. Часто это приводит к проблемам, например, невозможно создать свою функцию
MyStringFormatter(…)
которая бы принимала такой синтаксис, какPi:1:4
. Именно по этому, а также из-за того, что эта функция долгое время не была доступна в основных компиляторах, такая конструкция не очень популярна.
-
Unit-ы позволяют группировать общие функции и объекты (любые элементы языка, которые могут быть объявлены), для использования другими unit-ами и программами. Они эквивалентны модулям и пакетам в других языках. Они имеют секцию interface
, где объявляются доступные для других unit-ов и программ переменные, функции и т.п., и секцию implementation
, где описано, как они работают. Unit MyUnit
можно сохранить под именем myunit.pas
(название должно состоять из строчных букв с расширением .pas
).
link:code-samples_russian/myunit.pas[role=include]
Файл основной программы чаще всего сохраняется в виде файлов типа myprogram.lpr
(lpr
= Lazarus program file; в Delphi используются .dpr
). Следует отметить, что возможны и другие расширения, например, некоторые проекты используют расширение .pas
для основного файла программы. Для unit-ов изредка используются расширение .pp
. Лично же я предпочитаю использовать стандартные .pas
для unit-ов и .lpr
для FPC/Lazarus программ.
Программа может подключать unit с помощью ключевого слова uses
:
link:code-samples_russian/myunit_test.lpr[role=include]
Unit может также содержать секции initialization
и finalization
. В них размещается код, который выполняется при запуске и завершении выполнения программы, соответственно.
link:code-samples/initialization_finalization.pas[role=include]
Не только основная программа, но и unit-ы также могут ссылаться на другие unit-ы. Другой unit может войти в секцию interface или только в implementation. Первый вариант позволяет создавать новые определения (процедуры, типы…), используя или наследуя информацию из другого unit-а. Во втором варианте возможности более ограничены - если использовать unit в секции implementation, то применить его идентификаторы возможно лишь в рамках implementation данного unit-а.
link:code-samples_russian/anotherunit.pas[role=include]
Запрещено применять кольцевую взаимозависимость (cyclic reference) в разделе interface. Т.е. два unit-а не могут использовать друг друга в разделе interface.
Причина такого ограничения заключается в том, что для того, чтобы "понять" секцию interface unit-а, компилятор анализирует и "понимает" все unit-ы, перечисленные в uses в секции interface.
В Паскале это правило придерживается строго, что позволяет достичь высокой скорости компиляции и полностью автоматическое определение компилятором что именно необходимо перекомпилировать. В Паскале нет необходимости создания сложных Makefile
для выполнения простой задачи компиляции, а также нет нужды перекомпилировать всё лишь для того, чтобы удостовериться, что все зависимости правильно обновились.
Вполне возможно создавать кольцевые зависимости между unit-ами когда один из них "используется" только в implementation. Поэтому нормально для A
использовать B
в interface, и затем unit B
использует A
в implementation.
Различные unit-ы могут определять одинаковые идентификаторы. Чтобы поддерживать код простым для чтения и правки, обычно следует избегать таких совпадений, но не всегда это возможно. В таких случаях, последний unit в списке uses
"перетягивает одеяло на себя", т.е. идентификаторы определённые в нём скрывают одноимённые идентификаторы введённые другими unit-ами ранее.
Однако, возможно недвусмысленно определить unit предоставляющий идентификатор, с помощью конструкции MyUnit.MyIdentifier
. Это стандартное решение ситуации, когда используемый идентификатор из MyUnit
скрыт идентификатором из другого unit-а. Для достижения этой же цели можно просто перестроить порядок unit-ов в списке uses, однако это повлияет на все идентификаторы, что не всегда желательно.
{$mode objfpc}{$H+}{$J-}
program showcolor;
// unit-ы Graphics и GoogleMapsEngine определяют свои типы, которые называются одинаково - TColor.
uses Graphics, GoogleMapsEngine;
var
{ Это сработает не так, как ожидается, поскольку TColor
определяется последним unit-ом в списке - GoogleMapsEngine. }
// Color: TColor;
{ А так будет правильно. }
Color: Graphics.TColor;
begin
Color := clYellow;
WriteLn(Red(Color), ' ', Green(Color), ' ', Blue(Color));
end.
В случае unit-ов следует также помнить, что они могут иметь два списка uses
: один - в секции interface
, другой - в implementation
. Основное правило в этом случае звучит как: "позднейшие unit-ы скрывают все что было до этого" и применяется последовательно, что в свою очередь означает, что unit-ы использованные в секции implementation могут скрывать идентификаторы из unit-ов использованных в секции interface. Кроме того, не стоит забывать, что в процессе обработки секции interface данного unit-а компилятором, влияют лишь unit-ы использованные в секции interface. Это может сбить с толку в ситуациях, когда два на вид одинаковых объявления обрабатываются компилятором по-разному:
{$mode objfpc}{$H+}{$J-}
unit UnitUsingColors;
// НЕВЕРНЫЙ пример
interface
uses Graphics;
procedure ShowColor(const Color: TColor);
implementation
uses GoogleMapsEngine;
procedure ShowColor(const Color: TColor);
begin
// WriteLn(ColorToString(Color));
end;
end.
Unit Graphics
(из набора библиотек Lazarus LCL) определяет тип TColor
. Но компилятор указывает на ошибку в этом unit-е, указывая на то, что заявленная в секции Interface процедура ShowColor
не описана. Проблема в том, что unit GoogleMapsEngine
также определяет тип TColor
, который используется только в секции implementation
, следовательно он перекрывает определение TColor
в секции implementation
. Т.е. компилятор видит это буквально как:
{$mode objfpc}{$H+}{$J-}
unit UnitUsingColors;
// НЕВЕРНЫЙ пример
// демонстрирующий, как предыдущий пример "видит" компилятор
interface
uses Graphics;
procedure ShowColor(const Color: Graphics.TColor);
implementation
uses GoogleMapsEngine;
procedure ShowColor(const Color: GoogleMapsEngine.TColor);
begin
// WriteLn(ColorToString(Color));
end;
end.
В данном случае решение тривиальное: необходимо просто изменить implementation
, чтобы явно использовать TColor
из unit-а Graphics
. Это также можно исправить, переместив GoogleMapsEngine
в секцию interface до unit-а Graphics
. Впрочем, это может привести к другим последствиям внутри unit-а UnitUsingColors
, так как коснётся всех его определений.
{$mode objfpc}{$H+}{$J-}
unit UnitUsingColors;
interface
uses Graphics;
procedure ShowColor(const Color: TColor);
implementation
uses GoogleMapsEngine;
procedure ShowColor(const Color: Graphics.TColor);
begin
// WriteLn(ColorToString(Color));
end;
end.
Иногда возникает необходимость взять идентификатор из одного unit-а и передать его через другой unit. Т.е. в результате использование нового unit-а должно сделать доступным старый идентификатор в пространстве имён.
В некоторых случаях это делается из-за необходимости сохранить обратную совместимость с предыдущими версиями unit-а. А иногда таким образом удобно "скрыть" какой-либо unit для внутреннего пользования.
Это может быть осуществлено с помощью повторного объявления идентификатора в новом unit-е.
{$mode objfpc}{$H+}{$J-}
unit MyUnit;
interface
uses Graphics;
type
{ Используем TColor из unit-а Graphics для определения TMyColor. }
TMyColor = TColor;
{ Как вариант, можно переопределить его под тем же именем.
В таком варианте необходимо будет явно указать наименование unit-а,
иначе получится несогласованное определение "TColor = TColor". }
TColor = Graphics.TColor;
const
{ С константами это тоже работает. }
clYellow = Graphics.clYellow;
clBlue = Graphics.clBlue;
implementation
end.
Стоит отметить, что данный трюк не пройдёт с глобальными процедурами, функциями и переменными. В таком случае возникнет необходимость объявить постоянный указатель на процедуру в другом unit-е (см. Колбэки — они же события, они же указатели на функции, они же процедурные переменные), но такой код выглядит не совсем чисто.
Более оптимальным решением является создание тривиальной "функции-обёртки", которая под видом простого вызова функции из внешнего unit-а, просто передаёт ему параметры и возвращает принимаемые значения обратно.
Чтобы проделать то же с глобальными параметрами иногда используются глобальные (на уровне unit-а) свойства, см. Свойства.
В Паскале для объектно-ориентированного программирования чаще всего используются классы (classes). На базовом уровне класс просто является "контейнером", который может вмещать в себя:
-
поля (field) (иными словами "переменная внутри класса"),
-
методы (method) (иными словами "процедура или функция внутри класса"),
-
свойства (property) (удобный синтаксис для конструкции подобной полю, однако в действительности являющейся парой методов, используемых для чтения (getter) и записи (setter) чего-либо; детальнее см. [Properties]).
-
Вообще говоря, в классах можно разместить очень много различных вещей, см. Дополнительные возможности объявления классов и локальные классы, но об этом пойдёт речь чуть позже.
type
TMyClass = class
MyInt: Integer; // это "поле"
property MyIntProperty: Integer read MyInt write MyInt; // это "свойство"
procedure MyMethod; // это "метод"
end;
procedure TMyClass.MyMethod;
begin
WriteLn(MyInt + 10);
end;
Паскаль поддерживает наследование и виртуальные методы объектно-ориентированного программирования.
link:code-samples_russian/inheritance.lpr[role=include]
По умолчанию методы не являются виртуальными и для объявления их виртуальными необходимо использовать ключевое слово virtual
. Перекрытие или замещение виртуального метода осуществляется с помощью ключевого слова override
, иначе компилятор выдаст ошибку. Чтобы скрыть метод без перекрытия используется ключевое слово reintroduce
, впрочем, без особых на то причин, так делать не стоит.
Чтобы узнать, является ли некоторый класс из семейства классов конкретным его наследником можно использовать оператор is
. Для выполнения приведения типа класса к конкретному классу следует использовать оператор as
.
link:code-samples_russian/is_as.lpr[role=include]
Вместо приведения типа в виде X as TMyClass
, можно использовать приведение типа без проверки с помощью выражения TMyClass(X)
. Такой код будет работать чуть-чуть быстрее, но может привести к неопределённому поведению в случае если X
не является наследником TMyClass
. По этому конструкцию TMyClass(X)
лучше не применять кроме тех случаев, когда абсолютно очевидно, что X
действительно является наследником TMyClass
, например, если до этого тип класса был проверен с помощью оператора is
:
if A is TMyClass then
(A as TMyClass).CallSomeMethodOfMyClass;
// вариант ниже - работает незначительно быстрее
if A is TMyClass then
TMyClass(A).CallSomeMethodOfMyClass;
Свойства (Properties) являются "синтаксическим сахаром" (прим. перев. syntax sugar - жаргон, означающий синтаксические возможности, применение которых не влияет на поведение программы, но делает использование языка более удобным для программиста) который можно использовать с целью:
-
Сделать что-то внешнее похожее на поле (может быть прочитано и установлено), но реализовано вызовом функций считывания значения (getter) и установки значения (setter). Самое стандартное применение такого подхода - автоматическое выполннение дополнительных действий каждый раз, когда некоторое значение изменяется.
-
Сделать что-то внешне похожее на поле, но доступное только для чтения - нечто, похожее на константу или функцию без параметров.
type
TWebPage = class
private
FURL: string;
FColor: TColor;
function SetColor(const Value: TColor);
public
{ Значение URL невозможно установить напрямую.
Следует вызвать метод вроде Load('http://www.freepascal.org/'),
для загрузки страницы и установки значения этого свойства. }
property URL: string read FURL;
procedure Load(const AnURL: string);
property Color: TColor read FColor write SetColor;
end;
procedure TWebPage.Load(const AnURL: string);
begin
FURL := AnURL;
NetworkingComponent.LoadWebPage(AnURL);
end;
function TWebPage.SetColor(const Value: TColor);
begin
if FColor <> Value then
begin
FColor := Value;
{ Например, требовать обновления класса, каждый раз,
когда изменяется значение его цвета:
Repaint;
{ Ещё пример: обеспечить чтобы нечто изменялось синхронно
с установкой цвета, например }
RenderingComponent.Color := Value;
end;
end;
Стоит обратить внимание, что вместо того, чтобы указать метод для чтения или записи, можно напрямую указать читаемое/записываемое поле (которое обычно является private и весьма часто имеет название идентичное property с дополнительным префиксом f (от field - поле)) чтобы непосредственно получать или устанавливать значение. В примере выше, свойство Color
использует setter-метод SetColor
. Однако, для получения значения свойство Color
напрямую ссылается на private поле FColor
. Прямая ссылка на конкретное поле, очевидно, быстрее, чем написание дополнительных методов getter или setter - как с точки зрения разработки, так и с точки зрения скорости исполнения программы.
При объявлении свойства указывается:
-
Может ли оно быть прочитано, и как (с помощью прямого чтения поля, или с использованием метода
getter
). -
Может ли оно быть установлено, и как (с помощью прямой записи поля, или вызовом метода
setter
).
Компилятор следит за тем, чтобы типы и параметры соответствующих полей и методов совпадали с типом свойства с которым они работают. Например, чтобы прочитать свойство Integer
следует или предоставить поле Integer
, или беспараметрический метод (функцию), который возвращает Integer
.
С технической точки зрения, методы "getter" и "setter" - обычные методы и они могут делать абсолютно что угодно (включая массу дополнительных функций). Всё же правилом хорошего тона является создание таких свойств, которые ведут себя более-менее подобно обычному полю:
-
Функция getter не должна иметь невидимых побочных эффектов (например, она не должна читать некоторый ввод из файла / с клавиатуры). Её значение должно быть детерминистическим (без рандомизации или псевдо-рандомизации :)) - чтение свойства должно всегда иметь смысл и возвращать одинаковый результат, если между операциями чтения ничего не изменилось.
Следует отметить, что вполне нормально если выполнение getter имеет некие невидимые последствия, например, сохранение в кеше результатов какого-либо вычисления для ускорения выполнения кода при следующем вызове. По факту, это одна из очень полезных возможностей функции "getter".
-
Функция setter должна всегда устанавливать значение таким образом, чтобы getter вернул его же обратно. Не стоит автоматически отбрасывать неверные значения "setter", а в случае, когда это необходимо, следует вызвать exception. Также не желательно конвертировать или масштабировать запрашиваемое значение. Главная идея заключается в том, чтобы после установки
MyClass.MyProperty := 123;
можно было с уверенностью сказать, чтоMyClass.MyProperty = 123
. -
Свойства, доступные только для чтения часто используют для создания неких полей доступных из внешнего кода только для чтения. Снова таки, хорошая практика - делать их поведение похожим на константу, по крайней мере для данного экземпляра класса в его текущем состоянии. Значение такого свойства не должно меняться неожиданно. Если необходимо возвращать что-то случайное, лучше сделать функцию, а не свойство.
-
Поле, к которому обращаются свойства почти всегда находится в разделе private, поскольку главная идея свойств - служить обёрткой и методом доступа к нему.
-
Технически, возможно создать свойства, которые только устанавливают значение, но не читают его. Впрочем, хороших примеров такой реализации лично мне ещё не встречалось :)
Note
|
Свойства так же могут быть определены вне класса, на уровне unit-а. Они служат аналогичной цели: они внешне выглядят как глобальные переменные, но доступ к ним вызывает соответствующие функции getter и setter. |
Свойства, имеющие уровень Published являются основой для сериализации (serialization) (также называемой передачей компонент в потоке (streaming components)) в Паскале. Слово "Serialization" происходит от слова "Series" - "ряд", т.е. сериализация подаёт свойства объекта в виде некоторого линейного ряда данных, такого как например, запись в памяти или в файле.
Собственно, именно сериализация и происходит в момент, когда Lazarus читает или записывает состояние компонент из/в файл xxx.lfm
. В Delphi эквивалентный файл имеет расширение .dfm
. Этот механизм можно использовать и в своих целях, с помощью процедур таких как ReadComponentFromTextStream
из unit-а LResources
. Также можно использовать другие алгоритмы сериализации, например unit FpJsonRtti
представляет возможность сериализации в популярном формате JSON.
В Castle Game Engine можно использовать unit CastleComponentSerialize
(созданный на основе FpJsonRtti
) для сериализации иерархии наших собственных компонент, таких как user-interface и transformation.
При каждом свойстве можно указать дополнительные параметры, которые будут полезны при использовании любого алгоритма сериализации:
-
Можно указать значение свойства "по умолчанию" с помощью ключевого слова
default
. Обратите внимание, что всё равно следует инициализировать свойство с помощью значения по умолчанию - это не происходит автоматически. Значениеdefault
это лишь указание алгоритму сериализации: "когда закончится выполнение constructor-а, то данное свойство имеет это значение". -
Сохранять ли это свойство вообще - с помощью ключевого слова
stored
.
В паскале можно вызывать и использовать исключения. Их можно "ловить" с помощью конструкции try … except … end
, также можно применять секцию "выполнить в конце" try … finally … end
.
link:code-samples_russian/exception_finally.lpr[role=include]
Обратите внимание, что раздел finally
будет выполнен даже в случае, если выполнение будет прекращено командой Exit
(из функции, процедуры или метода), операторами Break
или Continue
(внутри тела цикла).
Как и в большинстве других объектно-ориентированных языков, в Паскале имеются визуальные спецификаторы для ограничения "видимости" полей / методов / свойств.
Основные уровни видимости являются следующими:
public
-
предоставлен доступ из любого участка кода, включая код в других unit-ах.
private
-
доступен только в этом классе.
protected
-
доступен только в этом классе и всех его наследниках.
Краткое описание private
и protected
, данное выше, не полностью верно. Код в текущем unit-е может преодолевать эти границы, и получать доступ к секции private
и protected
. Иногда это полезная особенность, позволяющая реализовывать тесно связанные классы. В остальных же случаях следует использовать strict private
или strict protected
для организации полной недоступности данных методов, полей или свойств извне класса. Детальнее этот вопрос рассматривается в разделе Различие private и strict private.
По умолчанию, если видимость не указана явно, то она соответствует public
. исключение составляют классы, которые объявляются при включённой директиве {$M+}
, либо наследники классов, которые были скомпилированы при {$M+}
, что включает в себя всех потомков TPersistent
, включая потомков TComponent
, который сам является потомком TPersistent
. Для таких классов по умолчанию видимость принимается published
, которая подобна public
, однако позволяет работать с ними с помощью потоковой (stream) системы.
Однако, не каждому типу поля или свойства позволено быть в секции published - не каждый тип может быть конвертирован в поток (stream), и лишь классы, состоящие из простых полей, могут передаваться потоком. Если нет необходимости создавать потоки, но нужно просто сделать что-то доступное для всех пользователей, то следует использовать public
.
Чтобы избежать утечек памяти, все экземпляры класса должны быть освобождены вручную. Хорошей практикой является использование опции компилятора FPC -gl -gh, чтобы обнаруживать утечки памяти (более подробно см. https://castle-engine.io/manual_optimization.php#section_memory).
Следует обратить внимание, что это не касается вызванных исключений (raised exceptions). Не смотря на то, что при вызове исключения действительно создаётся класс (и это вполне обычный класс, для этих целей также можно создавать и свои классы), такой экземпляр класса освобождается автоматически.
Самым удобным методом освобождения класса является операция FreeAndNil(A)
из unit-а SysUtils
вызванная для данного экземпляра класса. Она проверяет, не имеет ли A
значение nil
, и если нет — вызывает его деструктор (destructor), и устанавливает значение A
в nil
. Таким образом повторный вызов данной процедуры не приведёт к ошибке.
Приблизительно это соответствует следующему:
if A <> nil then
begin
A.Destroy;
A := nil;
end;
Впрочем, эта аналогия немного упрощена, поскольку процедура FreeAndNil
совершает ещё одно полезное действие, сразу устанавливая A
значение nil
до того как будет вызван destructor данного класса. Это позволяет избежать целой группы ошибок благодаря тому, что "внешний" код не сможет случайно получить доступ к не до конца уничтоженному экземпляру класса.
Иногда можно заметить, что применяется метод A.Free
который соответствует следующему коду:
if A <> nil then
A.Destroy;
Т.е. освобождает класс A
, если он не равен nil
.
Стоит отметить, что в нормальных условиях никогда не стоит вызывать метод класса, ссылка на который может оказаться nil
. По этому A.Free
может выглядеть подозрительно на первый взгляд, поскольку A
вполне может иметь значение nil
. Однако, метод Free
является исключением из этого правила. Это выглядит немного "грязновато" — а именно, выполняется проверка Self <> nil
. Такой фокус работает только для не-виртуальных методов (т.е. в случае, если не вызываются виртуальные методы и не требуется доступ к полям класса).
По этому лучше всегда использовать FreeAndNil(A)
, без исключений, и никогда не использовать метод Free
или напрямую деструктор Destroy
. Такой концепции придерживается Castle Game Engine. Это позволяет быть уверенным, что все ссылки либо равны nil
, либо указывают на существующий рабочий экземпляр класса.
Во многих ситуациях, освобождение экземпляра класса не является чем-то сложным. Просто пишется destructor, как пара соответствующему constructor-у, и освобождает все классы, память для которых была выделена в constructor-е (а, точнее, в продолжении всего времени существования класса). Важно следить за тем, чтобы освобождать каждый класс лишь один раз. Хорошей практикой будет всегда присваивать освобождённой ссылке значение nil
, а наиболее удобно сделать это, вызвав команду FreeAndNil(A)
.
Например:
uses SysUtils;
type
TGun = class
end;
TPlayer = class
Gun1, Gun2: TGun;
constructor Create;
destructor Destroy; override;
end;
constructor TPlayer.Create;
begin
inherited;
Gun1 := TGun.Create;
Gun2 := TGun.Create;
end;
destructor TPlayer.Destroy;
begin
FreeAndNil(Gun1);
FreeAndNil(Gun2);
inherited;
end;
Чтобы избежать необходимости каждый раз явным образом освобождать экземпляр класса, можно использовать полезную особенность TComponent
, которая называется "owner" (владение дочерним классом). Класс у которого есть owner (владелец) будет автоматически освобождён его owner-ом. Механизм очень гибкий и никогда не освобождает классы, которые уже освобождены, т.е. всё будет работать правильно в случае, если класс был освобождён ранее. Таким образом предыдущий пример можно переписать следующим образом:
uses SysUtils, Classes;
type
TGun = class(TComponent)
end;
TPlayer = class(TComponent)
Gun1, Gun2: TGun;
constructor Create(AOwner: TComponent); override;
end;
constructor TPlayer.Create(AOwner: TComponent);
begin
inherited;
Gun1 := TGun.Create(Self);
Gun2 := TGun.Create(Self);
end;
Следует обратить внимание, что также необходимо override виртуальный constructor от TComponent
. Это, в свою очередь, означает, что нельзя изменять параметры constructor-а. Впрочем, всё-таки это возможно — объявив новый constructor с ключевым словом reintroduce
. Однако здесь стоит быть осторожным, так как некоторый функционал, например, streaming, всё равно будет использовать виртуальный constructor, по этому следует удостовериться, что во всех возможных случаях всё будет работать корректно.
Обратите внимание, что всегда можно использовать nil
в качестве owner-а. Таким образом для данного компонента не будет owner-класса и класс не будет освобождён автоматически. Это может оказаться полезным, если необходимо использовать класс на основе TComponent
, сохранив при этом возможность освобождать его вручную. Таким образом просто создавать наследника компонента следующим образом: ManualGun := TGun.Create(nil);
.
Ещё один удобный механизм автоматического освобождения памяти — функционал OwnsObjects
(который по умолчанию равен true
) классов-списков, таких, как TFPGObjectList
или TObjectList
. Т.е. можно сделать следующим образом:
uses SysUtils, Classes, FGL;
type
TGun = class
end;
TGunList = specialize TFPGObjectList<TGun>;
TPlayer = class
Guns: TGunList;
Gun1, Gun2: TGun;
constructor Create;
destructor Destroy; override;
end;
constructor TPlayer.Create;
begin
inherited;
// Вообще говоря, параметр OwnsObjects и так true по умолчанию
Guns := TGunList.Create(true);
Gun1 := TGun.Create;
Guns.Add(Gun1);
Gun2 := TGun.Create;
Guns.Add(Gun2);
end;
destructor TPlayer.Destroy;
begin
{ Здесь достаточно освободить сам список.
Он сам автоматически освободит всё содержимое. }
FreeAndNil(Guns);
{ Таким образом нет нужды освобождать Gun1, Gun2 отдельно. Правда, хорошей
практикой будет теперь установить значение "nil" соответствующим значениям
ссылок на них, поскольку мы знаем, что они освобождены.
В этом простом классе с простым destructor-ом, очевидно,
что к ним не произойдёт доступа, однако в случае сложных destructor-ов
это может оказаться полезно.
Альтернативно, можно избежать объявления Gun1 и Gun2 отдельно
и использовать напрямую Guns[0] и Guns[1] в коде.
Можно также создать метод Gun1, который возвращает ссылку на Guns[0]. }
Gun1 := nil;
Gun2 := nil;
inherited;
end;
Заметим, что механизм owner-ов классов-списков простой (без дополнительных проверок) и, в случае освобождения содержащегося в списке экземпляра класса сторонним кодом, впоследствии возникнет ошибка. Чтобы исключить что-либо из списка без освобождения используется метод Extract
, однако это также означает, что в дальнейшем элемент придётся освободить вручную.
Например, в Castle Game Engine все наследники класса TX3DNode
автоматически управляют памятью при добавлении другой TX3DNode
в список children. Корневая X3DNode называется TX3DRootNode
и, в свою очередь, обычно имеет своим owner-ом класс TCastleSceneCore
. Другие классы также имеют простой механизм owner-а — обычно это обозначено параметром или свойством под названием вида OwnsXxx
.
Если на экземпляр класса создано несколько ссылок, это эквивалентно тому, что две ссылки указывают на одну и ту же область памяти. Если освободить одну из них, вторая окажется "болтающимся" pointer-ом. Нельзя пытаться получить доступ к области памяти, которая была освобождена. Это может привести к runtime ошибке, либо может быть получено неопределённое значение ("мусор") — в случае, если эта область памяти уже была повторно выделена для других элементов внутри текущей программы.
В таком случае не достаточно просто вызывать FreeAndNil
поскольку эта функция установит nil
лишь для переданной ей ссылки — автоматического метода для подобных задач не существует. Рассмотрим следующий пример:
var
Obj1, Obj2: TObject;
begin
Obj1 := TObject.Create;
Obj2 := Obj1;
FreeAndNil(Obj1);
// что произойдёт, если попытаться получить доступ к классу Obj1 или Obj2?
end;
-
В конце данного блока ссылка
Obj1
являетсяnil
. Если необходимо получить доступ к ней в коде программы, для надёжности следует использовать проверкуif Obj1 <> nil then …
чтобы случайно не вызвать метод уже освобождённого экземпляра класса, например:if Obj1 <> nil then WriteLn(Obj1.ClassName);
Попытка доступа к ссылке
nil
на экземпляр класса приведёт к предсказуемой и понятной ошибке. Таким образом, даже если код не будет проверятьObj1 <> nil
, и попытается вслепую получить доступ кObj1
, возникнет достаточно ясное сообщение об ошибке.То же самое происходит и при попытке вызова виртуального, или не-виртуального метода который пытается получить доступ к полю освобождённого экземпляра класса.
-
Ситуация с
Obj2
— куда сложнее. Её значение неnil
, однако оно уже ошибочно. Попытка доступа к не-nil
ссылки на несуществующий экземпляр класса приводит к непредсказуемому результату — это может быть и ошибка access violation, а может и просто какое-то случайное значение.
К решению такой проблемы есть несколько путей:
-
Первое решение - внимательно читать документацию к классу. Не предполагать ничего о длительности жизни ссылки, если она создана чужим кодом. Если в классе
TCar
есть полеwheel
, указывающее на экземпляр класса типаTWheel
, то есть правило что ссылка наwheel
верна, пока существует классcar
, и самcar
освободит все егоwheel
используя свой destructor. Но это правило не всегда возможно выполнить. В более сложных случаях, в документации следует сделать упоминание о том, что и как происходит со ссылками. -
В примере выше, сразу после освобождения экземпляра класса
Obj1
, можно просто вручную установитьObj2
значениеnil
. В данном конкретном примере тривиально. -
Однако, наиболее перспективным решением будет применение специального механизма класса
TComponent
под названием "free notification" (извещение об освобождении). Таким образом один компонент может получить извещение в случае освобождения одной из компонент, и далее установить ссылку на неё вnil
.Таким образом можно получить слабую ссылку. Использовать эту механику можно в различных задачах, например, позволить коду извне изменять ссылки, в том числе, возможность освобождать память в любой момент.
Для этого оба класса должны наследовать
TComponent
. Обычно это сводится к использованиюFreeNotification
,RemoveFreeNotification
, и overrideNotification
.Следующий пример демонстрирует как использовать этот подход вместе с constructor-ом / destructor-ом и setter-ом. Иногда можно всё сделать намного проще, но здесь демонстрируется полномасштабная версия, которая будет верной в любом случае.
type TControl = class(TComponent) end; TContainer = class(TComponent) private FSomeSpecialControl: TControl; procedure SetSomeSpecialControl(const Value: TControl); protected procedure Notification(AComponent: TComponent; Operation: TOperation); override; public destructor Destroy; override; property SomeSpecialControl: TControl read FSomeSpecialControl write SetSomeSpecialControl; end; implementation procedure TContainer.Notification(AComponent: TComponent; Operation: TOperation); begin inherited; if (Operation = opRemove) and (AComponent = FSomeSpecialControl) then { установить значение nil для SetSomeSpecialControl чтобы всё аккуратно подчистить } SomeSpecialControl := nil; end; procedure TContainer.SetSomeSpecialControl(const Value: TControl); begin if FSomeSpecialControl <> Value then begin if FSomeSpecialControl <> nil then FSomeSpecialControl.RemoveFreeNotification(Self); FSomeSpecialControl := Value; if FSomeSpecialControl <> nil then FSomeSpecialControl.FreeNotification(Self); end; end; destructor TContainer.Destroy; begin { Установить значение nil для SetSomeSpecialControl, чтобы запустить notification про освобождение памяти } SomeSpecialControl := nil; inherited; end;
Для ввода / вывода в современных программах на Паскале используется класс TStream
. У него также есть множество полезных производных классов, таких как TFileStream
, TMemoryStream
, TStringStream
и т.п.
link:code-samples_russian/file_stream.lpr[role=include]
В Castle Game Engine следует использовать функцию Download
для создания потока, который загружает данные с любого заданного URL. Это могут быть обычные файлы, ресурсы, хранящиеся на HTTP и HTTPS, Android assets и много других. Более того, для однозначного кросс-платформенного указания на папку игровых данных (хранящихся в папке data
) следует использовать функцию ApplicationData
или особый тип URL castle-data:/xxx
URL. Например:
EnableNetwork := true;
S := Download('https://castle-engine.io/latest.zip');
S := Download('file:///home/michalis/my_binary_file.data');
Чтобы прочитать обычный текстовый файл лучше использовать класс TTextReader
из CastleClassUtils
. Он предоставляет построчный API, как надстройку над TStream
. URL можно задать через конструктор класса TTextReader
, либо можно передать ему готовый TStream
вручную.
Text := TTextReader.Create('castle-data:/my_data.txt');
while not Text.Eof do
WriteLnLog('NextLine', Text.ReadLine);
Для создания различных списков переменной длинны, лично я советую использовать generic классы из unit-а FGL
. Можно использовать TFPGList
для простых типов данных (включая record-ы или устаревшие object-ы), и TFPGObjectList
для списка экземпляров классов. В Castle Game Engine: можно также использовать TGenericStructList
из CastleGenericLists
для создания списков record-ов или устаревших object-ов. Это позволяет обойти проблему невозможности override их операторов в старых версиях FPC.
Применение таких списков является хорошей идеей по нескольким причинам: они типо-безопасны и их API имеет много полезных функций, таких как поиск, сортировка, итерация и т.п. Вообще говоря, не стоит использовать динамические массивы (array of X
, SetLength(X, …)
) поскольку их API очень неудобный - можно применить лишь SetLength
. Также не желательно использовать TList
или TObjectList
поскольку это потребует преобразование типа из общего TObject
в конкретный тип класса при каждом обращении.
Стандартным подходом для создания копии экземпляра класса является наследование этим классом TPersistent
с последующим override его метода Assign
. Здесь нет ничего сложного, просто нужно в методе Assign
прописать копирование полей, которые Вам необходимы.
Однако, здесь понадобится достаточно аккуратный подход в имплементации метода Assign
, поскольку копирование может происходить не только из данного класса, а и из производных от него.
link:code-samples_russian/persistent.lpr[role=include]
Иногда более удобно написать альтернативный override метода AssignTo
в копируемом классе, а не делать override метода Assign
в классе, в который выполняется копирование.
Следует быть осторожным с inherited
при написании Assign
. Inherited из TPersistent.Assign
должен вызываться исключительно в случаях, если Вы не можете самостоятельно выполнить копирование в своём коде (это позволит использовать метод AssignTo
и создавать сообщения об ошибках, в случае, если копирование не может быть выполнено). С другой стороны, если данный класс является производным от класса, в котором уже есть метод Assign
, то в данном случае следует обязательно вызывать inherited из TMyClass.Assign
. См. пример выше.
Note
|
Обратите внимание, что наследуя класс TPersistent по умолчанию видимость полей становится published , чтобы дать возможность переводить в поток (streaming) производные от TPersistent классы. Однако, не все типы полей и свойств разрешены в секции published . Если возникают связанные с этим ошибки и нет необходимости передавать эти данные в поток, просто вручную измените уровень видимости на public . Детальнее см. раздел Уровни видимости.
|
Внутри большой процедуры (это может быть функция, процедура, метод и т.п.) можно определить вложенную (местную) под-процедуру.
Таким образом такая локальная под-процедура имеет полный доступ (чтение и запись) всех параметров процедуры, в которой она находится, если они были объявлены раньше, чем эта процедура. Это очень удобно, позволяя разделять длинные процедуры на несколько небольших частей без необходимости передавать множество информации в виде параметров. Однако, не следует злоупотреблять этой возможностью. В случае, если местная (вложенная) процедура использует и, тем более, изменяет много переменных процедуры, в которой она находится, такой код становится сложным для понимания.
Данные два примера абсолютно эквивалентны:
function SumOfSquares(const N: Integer): Integer;
function Square(const Value: Integer): Integer;
begin
Result := Value * Value;
end;
var
I: Integer;
begin
Result := 0;
for I := 0 to N do
Result := Result + Square(I);
end;
И второй вариант, в котором Square
получает прямой доступ к переменной I
:
function SumOfSquares(const N: Integer): Integer;
var
I: Integer;
function Square: Integer;
begin
Result := I * I;
end;
begin
Result := 0;
for I := 0 to N do
Result := Result + Square;
end;
Местные процедуры могут быть многократно вложенными — что означает, что внутри локальной процедуры можно определить местную локальную под-процедуру и т.д. Однако, не желательно этим увлекаться, поскольку в результате код может стать совершенно нечитабельным.
Они позволяют вызвать функцию не прямым указанием её названия, а посредством переменной. Эта переменная может быть назначена во время исполнения кода для указания на любую функцию с указанными типами параметров и возвращаемых величин.
Колбэки могут быть:
-
Обычными, что означает, что они могут указывать на любую нормальную функцию (т.е. не на метод или локальную функцию)
link:code-samples/callbacks.lpr[role=include]
-
Указывающими на метод: для этого в конце добавляется
of object
.link:code-samples/callbacks_of_object.lpr[role=include]
Следует заметить, что невозможно передать обычные процедуры или функции как методы. Они несовместимы. Если Вам необходимо использовать колбэк
of object
, но не хотите создавать экземпляр-пустышку для класса, есть возможность передавать Class method в качестве метода.type TMyMethod = function (const A, B: Integer): Integer of object; TMyClass = class class function Add(const A, B: Integer): Integer class function Multiply(const A, B: Integer): Integer end; var M: TMyMethod; begin M := @TMyClass(nil).Add; M := @TMyClass(nil).Multiply; end;
К сожалению, в данном случае приходится писать громоздкую конструкцию
@TMyClass(nil).Add
, а не просто@TMyClass.Add
. -
Может указывать на локальную процедуру: для этого её нужно объявить с
is nested
в конце, а также установить директиву{$modeswitch nestedprocvars}
для данного участка кода. Детальнее см. Местные (вложенные) процедуры.
Generic-и - это очень мощная функция современных языков. Определение чего-либо (обычно - класса) может быть конкретизировано другим типом. Наиболее естественным примером является необходимость создать контейнер (список, словарь, дерево, граф…): можно определить список типа T, а потом specialize (конкретизировать) его чтобы сразу получить список из integer, список из string, список из TMyRecord и т.д.
Generic-и в Паскале реализованы весьма подобно generic-ам в C++. Это означает, что они "конкретизируются" когда происходит specialize, что немного похоже на поведение макросов (однако, когда тип "конкретизирован", используются только эти конкретные определения, и таким образом невозможно "добавить" какое-либо неожиданное поведение в generic после его "конкретизации"). Преимуществом их является высокая скорость выполнения (оптимизированная для каждого конкретного типа) и поддержка типов любого размера. Можно использовать примитивные типы (integer, float) а также record, class и т.п. при specialize данного generic-а.
link:code-samples_russian/generics.lpr[role=include]
Возможности generic-ов не ограничиваются классами, можно создать generic функции и процедуры:
link:code-samples_russian/generic_functions.lpr[role=include]
Методы (а также, глобальные функции и процедуры) с одинаковым именем могут существовать при условии, что они принимают разные параметры. На основании передаваемых процедуре параметров компилятор определяет, какую именно из этих функций использовать в данном случае.
По умолчанию overload использует стиль FPC. Это означает, что абсолютно все имена в пространстве имён (в классе или unit-е) равны, и скрывает все остальные методы в пространстве имён с более низким приоритетом. Например, если создать класс с методом Foo(Integer)
и Foo(string)
, и при этом класс наследует класс в котором уже есть Foo(Float)
, то метод Foo(Float)
невозможно будет вызвать напрямую, только с помощью приведения типа данного класса к родительскому классу. Чтобы избежать такого поведения, при объявлении процедур и функций следует использовать ключевое слово overload
.
Можно использовать простые директивы предобработки кода с целью:
-
компиляции на основании различных условий (например, если код зависит от платформы, или других заданных вручную параметров),
-
чтобы включить один файл внутри другого,
-
для вызова беспараметрических макросов.
Обратите внимание, макросы с параметрами запрещены. В общем, следует избегать предобработки кода кроме случаев, когда она действительно необходима. Предобработка происходит перед парсингом, что значит, что она вполне может "ломать" обычный синтаксис языка Паскаль. Это мощно, но немного "грязно".
{$mode objfpc}{$H+}{$J-}
unit PreprocessorStuff;
interface
{$ifdef FPC}
{ всё что идёт внутри данного условия ifdef определено только для FPC, а не других компиляторов (например, Delphi). }
procedure Foo;
{$endif}
{ Определить константу NewLine. Это пример того, как "нормальный"
синтаксис Паскаля "поломан" директивами предобработки.
Если компилировать на Unix-системах (включая Linux, Android, Mac OS X),
компилятор увидит следующее:
const NewLine = #10;
Если компилировать на Windows, компилятор увидит так:
const NewLine = #13#10;
Однако, на других операционных системах, код не скомпилируется,
поскольку компилятор увидит следующее:
const NewLine = ;
Вообще, это *хорошо* что в данном случае возникает ошибка -- если возникнет
необходимость портировать программу на другую операционную систему,
которая не является ни Unix, ни Windows, то компилятор "напомнит", что
необходимо выбрать правильное значение NewLine для такой системы. }
const
NewLine =
{$ifdef UNIX} #10 {$endif}
{$ifdef MSWINDOWS} #13#10 {$endif} ;
{$define MY_SYMBOL}
{$ifdef MY_SYMBOL}
procedure Bar;
{$endif}
{$define CallingConventionMacro := unknown}
{$ifdef UNIX}
{$define CallingConventionMacro := cdecl}
{$endif}
{$ifdef MSWINDOWS}
{$define CallingConventionMacro := stdcall}
{$endif}
procedure RealProcedureName; CallingConventionMacro; external 'some_external_library';
implementation
{$include some_file.inc}
// $I это удобное сокращение от $include, они идентичны
{$I some_other_file.inc}
end.
Включаемые файлы обычно имеют расширение .inc
, и используются для следующих двух целей:
-
Включаемый файл может содержать лишь другие директивы компилятора, которые "конфигурируют" исходный код программы. Например, можно создать файл
myconfig.inc
со следующим содержанием:{$mode objfpc} {$H+} {$J-} {$modeswitch advancedrecords} {$ifndef VER3} {$error Этот код может быть скомпилирован только в версии FPC не ниже 3.x.} {$endif}
Теперь можно включить этот файл
{$I myconfig.inc}
в каждом unit-е исходного кода. -
Второй важный пример использования - разделить unit на несколько файлов, и при этом всё же оставить его одним unit-ом (с учётом всех особенностей Паскаля). Не стоит злоупотреблять таким подходом. В первую очередь стоит думать о том, как разделить программу на несколько unit-ов, а не разделять её на множество include файлов. Однако, в общем, это довольно полезная и удобная техника.
-
Такой подход позволяет избежать огромного количества unit-ов, и при этом исходные файлы не окажутся слишком длинными. Например, куда удобнее иметь один файл с "часто используемыми GUI элементами", чем создавать отдельные unit-ы для каждого GUI элемента", поскольку второй вариант сделает обычную "uses" неоправданно длинной (поскольку в GUI-приложении будут использоваться множество GUI элементов). Однако, размещение всех этих классов в одном файле
myunit.pas
приведёт к его большому размеру, что в свою очередь затруднит навигацию по файлу. Таким образом, разбить этот файл на несколько include файлов будет разумным. -
Такой подход также позволяет легко создавать кросс-платформенный unit с уникальными частями для каждой платформы, что, например, может выглядеть следующим образом:
{$ifdef UNIX} {$I my_unix_implementation.inc} {$endif} {$ifdef MSWINDOWS} {$I my_windows_implementation.inc} {$endif}
Иногда это более удобно, чем писать длинный код со множеством
{$ifdef UNIX}
,{$ifdef MSWINDOWS}
вперемешку с нормальным кодом (определение переменных, описание процедур). Повышается и читабельность кода. Эту технику можно применять более агрессивно, используя опцию командной строки-Fi
при вызове FPC, чтобы включить целые папки только для конкретных платформ. Таким образом можно организовать множество версий include файлов типа{$I my platform_specific_implementation.inc}
. При этом компилятор автоматически найдёт правильную версию.
-
Record является своеобразным контейнером для других переменных. Она является очень, очень упрощённым классом: в record-ах нет наследования, нет виртуальных методов. Они несколько подобны structure в C-подобных языках.
Применяя директиву {$modeswitch advancedrecords}
, появляется возможность добавить методы и уровни видимости в record и использовать любые особенности языка, доступные классам, которые не нарушают простую и предсказуемую раскладку памяти для record.
link:code-samples_russian/records.lpr[role=include]
В современном Паскале, в первую очередь лучше использовать class
, а не record
, поскольку классы имеют множество дополнительных полезных возможностей, как конструкторы и наследование.
Однако, record-ы всё ещё могут оказаться полезными если необходима скорость или предсказуемая раскладка памяти:
-
Record-ам не требуется constructor или destructor. Они определяются как переменные. Их содержимое не определено в начале (так называемый "мусор в памяти"), кроме случаев автоматически управляемых типов, таких как string, которые всегда инициализируются пустыми. Таким образом следует быть более аккуратным при их использовании, однако преимуществом такого подхода будет увеличение скорости исполнения программы.
-
Массивы, состоящие из record-ов однородно располагаются в памяти и таким образом их удобно кешировать.
-
Разметка памяти (размер и расстояние между каждым полем) для record-ов чётко определена в некоторых ситуациях: когда запрашивается C layout или если используется
packed record
. Это может быть полезным в следующих случаях:-
для связи между библиотеками, написанными в других языках программирования и предоставляющих API, который основывается на данных типа record,
-
для чтения и записи бинарных файлов,
-
для выполнения "грязных" низкоуровневых оптимизаций (как небезопасное приведение одного типа в другой, на основании их однозначного представления в памяти).
-
-
В record-ах можно также использовать часть
case
, которая работает как union в C-подобных языках и позволяет интерпретировать одну и ту же область памяти, как различные типы данных, в зависимости от необходимости. Таким образом можно достичь более высокой эффективности использования памяти в некоторых случаях. В том числе, можно выполнять некоторые "грязные" низкоуровневые оптимизации.
Давным-давно, в Turbo Pascal был введён новый тип синтаксиса с функционалом, похожим на классы, который задавался ключевым словом object
. По сути, это - нечто среднее между record
и современным class
.
-
Такие object-ы можно создавать / освобождать, и в процессе этого можно вызвать их constructor / destructor.
-
Но их можно просто объявить и использовать как обычную record. Простые типы
record
илиobject
не являются указателями (pointer-ами) на что-либо, они, собственно, являются данными. Это делает их весьма удобными в случаях небольших объёмов информации, когда многократное распределение или освобождение памяти не всегда оправдано. -
Устаревшие object-ы имеют наследование и виртуальные методы, впрочем, в несколько отличном виде от современных классов. Следует быть внимательным: если попытаться использовать object, имеющий виртуальные методы, без вызова его constructor-а, могут возникнуть ошибки.
В большинстве случаев крайне не советуется использовать устаревшие objects-ы. Современные class-ы имеют гораздо более широкий функционал. А в случае если необходимо повысить скорость выполнения, можно использовать record-ы (включая advanced records). Такие подходы обычно лучше, чем устаревшие object-ы.
В Паскале возможно создать pointer (указатель) на любой тип данных. Указатель на тип TMyRecord
определяется с помощью синтаксиса ^TMyRecord
, и чаще всего такие pointer-ы называют PMyRecord
. В качестве классического примера рассмотрим связанный список целых чисел, организованный с помощью record:
type
PMyRecord = ^TMyRecord;
TMyRecord = record
Value: Integer;
Next: PMyRecord;
end;
Следует обратить внимание, что здесь было использовано рекурсивное определение (тип PMyRecord
определяется с помощью TMyRecord
, а TMyRecord
использует в своём определении PMyRecord
). Можно определить pointer на тип, который ещё не был объявлен, в том случае, если он будет определён в том же самом блоке type
.
Можно распределять и освобождать память для pointer-ов с помощью методов New
/ Dispose
, или (более низкоуровневых, но не типобезопасных) методов GetMem
/ FreeMem
. Для доступа к данным, на которые pointer указывает, после него следует добавить оператор ^
(в виде MyInteger := MyPointerToInteger^
). Обратная операция (получить pointer на существующую переменную) выполняется с помощью оператора-префикса @
(например, MyPointerToInteger := @MyInteger
).
Существует также самый общий тип Pointer
, который не указывает на конкретный тип данных и подобен void*
в C-подобных языках. Он совершенно не типобезопасен, и его можно привести его тип в любой другой тип pointer-а.
Следует помнить, что экземпляр класса также является pointer-ом, хоть для работы с ним и не нужно использовать дополнительных операторов ^
и @
.
Рекурсивные определения также возможны и для классов, и в данном случае всё будет выглядеть ещё проще:
type
TMyClass = class
Value: Integer;
Next: TMyClass;
end;
В Паскале существует возможность "перегрузить" (overload) значение многих операторов языка, например определить сложение и умножение пользовательских типов данных. Рассмотрим следующий пример:
link:code-samples_russian/operator_overloading.lpr[role=include]
Можно также перегружать операторы над классами. Учитывая то, что обычно в такой функции-операторе создаётся новый экземпляр класса, вызывающий код должен позаботиться об надлежащем освобождении памяти.
link:code-samples/operator_overloading_classes.lpr[role=include]
Можно перегружать и операции над record-ами. Обычно это проще, чем в случае классов, поскольку нет необходимости выполнять операции по распределению или освобождению памяти.
link:code-samples/operator_overloading_records.lpr[role=include]
При работе с record-ами лучше использовать режим {$modeswitch advancedrecords}
и перегружать операторы в виде class operator
внутри объявления record-а. Такой подход позволяет использовать generic классы которые зависят от существования какого-либо оператора (например TFPGList
, который зависит от доступности оператора равенства). В противоположном случае "глобальное" определение оператора (не внутри объявления данной record) не будет найдено (поскольку оно не доступно коду, который используется в TFPGList
), и не удастся specialize список в виде specialize TFPGList<TMyRecord>
.
link:code-samples/operator_overloading_records_lists.lpr[role=include]
Спецификатор видимости private
означает что поле или метод не доступны извне данного класса. Однако из данного правила есть одно исключение: любой код в данном unit-е может преодолеть это ограничение и иметь полный доступ к private полям и методам. Программист на C++ сказал бы, что в Паскале все классы в одном unit-е являются "друзьями". Часто это весьма удобно.
Однако, при создании unit-ов большого размера со множеством не очень близко интегрированных классов, более безопасным является применение видимости strict private
. Как можно легко догадаться, это означает что поле или метод не доступны извне данного класса. Точка. Никаких исключений.
Таким же образом различаются спецификаторы видимости protected
(видимый наследникам данного класса или "друзьям" в том же unit-е) и strict protected
(видимы только и исключительно наследникам данного класса. Точка).
Внутри определения класса можно создавать константы (const
) или типы (type
). Таким образом можно даже создать класс внутри класса. Спецификаторы видимости будут работать как обычно, и такие локальные классы могут быть private (не видимые "внешнему миру"), что иногда удобно.
Следует обратить внимание, чтобы определить поле после константы или типа будет необходимо использовать блок var
:
type
TMyClass = class
private
type
TInternalClass = class
Velocity: Single;
procedure DoSomething;
end;
var
FInternalClass: TInternalClass;
public
const
DefaultVelocity = 100.0;
constructor Create;
destructor Destroy; override;
end;
constructor TMyClass.Create;
begin
inherited;
FInternalClass := TInternalClass.Create;
FInternalClass.Velocity := DefaultVelocity;
FInternalClass.DoSomething;
end;
destructor TMyClass.Destroy;
begin
FreeAndNil(FInternalClass);
inherited;
end;
{ Обратите внимание на префикс "TMyClass.TInternalClass." }
procedure TMyClass.TInternalClass.DoSomething;
begin
end;
Существуют методы, которые можно вызвать из класса вообще (TMyClass
), не обязательно из его конкретного экземпляра.
type
TEnemy = class
procedure Kill;
class procedure KillAll;
end;
var
E: TEnemy;
begin
E := TEnemy.Create;
try
E.Kill;
finally
FreeAndNil(E);
end;
TEnemy.KillAll;
end;
Обратите внимание, что они также могут быть виртуальными — иногда это весьма удобно, особенно, если применяется совместно с понятием Ссылки на класс.
Следует отметить, что constructor всегда работает, как class method когда вызывается "обычным образом" (MyInstance := TMyClass.Create(…);
). Впрочем, можно вызвать конструктор в данном конкретном экземпляре класса как метод — и он тогда и сработает как обычный метод. Таким образом можно сделать удобную "цепочку" constructor-ов, когда один constructor (например, перегруженный для принятия дополнительных параметров) выполняет определённый код, а затем вызывает другой constructor (например, беспараметрический).
Ссылки на класс позволяют выбрать класс в процессе исполнения программы, например, для вызова class method-а или constructor-а не определив заранее, какой именно класс будет использоваться. Такой тип объявляется следующим образом: class of TMyClass
.
type
TMyClass = class(TComponent)
end;
TMyClass1 = class(TMyClass)
end;
TMyClass2 = class(TMyClass)
end;
TMyClassRef = class of TMyClass;
var
C: TMyClass;
ClassRef: TMyClassRef;
begin
// Можно сделать так:
C := TMyClass.Create(nil); FreeAndNil(C);
C := TMyClass1.Create(nil); FreeAndNil(C);
C := TMyClass2.Create(nil); FreeAndNil(C);
// А с помощью ссылки на класс можно сделать следующим образом:
ClassRef := TMyClass;
C := ClassRef.Create(nil); FreeAndNil(C);
ClassRef := TMyClass1;
C := ClassRef.Create(nil); FreeAndNil(C);
ClassRef := TMyClass2;
C := ClassRef.Create(nil); FreeAndNil(C);
end;
Ссылки на класс можно комбинировать с виртуальными class method-ами. Работает это подобно классам с виртуальными методами — конкретный метод, который необходимо выполнить определяется уже в процессе выполнения программы.
type
TMyClass = class(TComponent)
class procedure DoSomething; virtual; abstract;
end;
TMyClass1 = class(TMyClass)
class procedure DoSomething; override;
end;
TMyClass2 = class(TMyClass)
class procedure DoSomething; override;
end;
TMyClassRef = class of TMyClass;
var
C: TMyClass;
ClassRef: TMyClassRef;
begin
ClassRef := TMyClass1;
ClassRef.DoSomething;
ClassRef := TMyClass2;
ClassRef.DoSomething;
{ А следующая строка приведёт к ошибке выполнения,
поскольку DoSomething является abstract в TMyClass. }
ClassRef := TMyClass;
ClassRef.DoSomething;
end;
Если есть экземпляр класса и необходимо создать ссылку на этот класс (не на какой-либо объявленный класс, а на конкретного наследника, который был использован при создании данного экземпляра класса), можно использовать свойство ClassType
. Вообще говоря, объявленный тип ClassType
является TClass
, который в свою очередь является class of TObject
. Его тип можно привести во что либо более конкретное, когда есть информация, чем именно является данный экземпляр.
ClassType
также можно использовать для вызова виртуальных методов, включая виртуальные constructor-ы. Такой подход позволяет создать такие методы, как Clone
которые создают экземпляр как точную копию данного класса в конкретный момент исполнения программы. Можно совместить такой подход с Assign (см. Клонирование классов: TPersistent.Assign) для того, чтобы метод возвращал новую, готовую к работе копию текущего экземпляра.
Следует обратить внимание, что подобный подход сработает только если constructor данного класса виртуальный. Например, его можно использовать с наследниками стандартного класса TComponent
, поскольку все они выполняют override виртуального constructor-а TComponent.Create(AOwner: TComponent)
.
type
TMyClass = class(TComponent)
procedure Assign(Source: TPersistent); override;
function Clone(AOwner: TComponent): TMyClass;
end;
TMyClassRef = class of TMyClass;
function TMyClass.Clone(AOwner: TComponent): TMyClass;
begin
// Таким образом будет создан класс конкретного типа TMyClass:
// Result := TMyClass.Create(AOwner);
// А такой подход может создать класс как типа TMyClass, так и его наследников:
Result := TMyClassRef(ClassType).Create(AOwner);
Result.Assign(Self);
end;
Метод является лишь процедурой внутри конкретного класса. Извне класса он вызывается с помощью специального синтаксиса MyInstance.MyMethod(…)
. И через некоторое время приходит привычка, что если нужно произвести действие над классом X, следует писать X.Action(…)
.
Однако, иногда возникает необходимость выполнить что-либо, что концептуально является действием на класс TMyClass, однако при этом не изменяя исходный код TMyClass. Причин тому может быть несколько. Например, это может быть исходный код, написанный другим программистом, который не следует или невозможно изменять. Также иногда причиной тому могут быть зависимости — добавление метода Render
к классу TMy3DObject
кажется вполне логичным, однако, возможно, имплементация класса TMy3DObject
должна быть независимой от кода рендера? В таких случаях удобнее "расширить" существующий класс, добавив к нему функционал, при этом не изменяя его исходный код.
Наиболее простой путь сделать это - создать глобальную процедуру, которая будет принимать ссылку на TMy3DObject
как параметр.
procedure Render(const Obj1: TMy3DObject; const Color: TColor);
var
I: Integer;
begin
for I := 0 to Obj1.ShapesCount - 1 do
RenderMesh(Obj1.Shape[I].Mesh, Color);
end;
И это действительно сработает. Однако, недостаток такого подхода - он выглядит не очень красиво. Ведь обычно мы вызываем действия над классом с помощью X.Action(…)
, а тут нам приходится использовать иной синтаксис: Render(X, …)
. Было бы куда удобнее записать X.Render(…)
, даже для случаев, когда Render
не описан в unit-е, в котором находится TMy3DObject
.
Для этого и существуют class helper-ы, дающие возможность создать процедуры/функции, которые оперируют данным классом и вызываются таким же образом, как и остальные методы класса. Однако они не являются "обычными" методами — они "добавляются" извне определения класса TMy3DObject
.
type
TMy3DObjectHelper = class helper for TMy3DObject
procedure Render(const Color: TColor);
end;
procedure TMy3DObjectHelper.Render(const Color: TColor);
var
I: Integer;
begin
{ Обратите внимание, мы получаем доступ к ShapesCount, Shape без дополнительных указаний типа TMy3DObject.ShapesCount }
for I := 0 to ShapesCount - 1 do
RenderMesh(Shape[I].Mesh, Color);
end;
Note
|
Более общая концепция является "type helper", используя которую становится возможным добавлять методы даже к самым примитивным типам, например integer. Можно также создать "record helper" чтобы… ну, Вы поняли. Детальнее см. здесь: http://lists.freepascal.org/fpc-announce/2013-February/000587.html. |
Имя destructor-а всегда должно быть Destroy
, и он всегда является virtual (поскольку он вызывается без указания конкретного класса при компиляции) и беспараметрический.
В качестве имени constructor-а принято использовать Create
. Можно использовать и другое имя, однако здесь следует быть аккуратным — например, создав CreateMy
, всегда создайте и Create
, иначе constructor Create
будет всё равно доступен из родительского класса и может быть вызван в обход конкретного CreateMy
конструктора.
В базовом классе TObject
constructor не является виртуальным, и при создании наследников его можно изменить. Новый constructor просто спрячет constructor родительского класса (примечание: не используйте overload
, в данном случае это не сработает).
В наследниках же класса TComponent
, следует выполнять constructor Create(AOwner: TComponent); override;
. Для решения задач потоковой передачи данных, а также для создания класса без указания конкретного типа при написании программы, виртуальные constructor-ы являются очень удобными (см. раздел Ссылки на класс выше).
Что произойдёт если в процессе выполнения constructor-а возникнет ошибка? Строка
X := TMyClass.Create;
не будет выполнена до конца, и X
не может быть присвоено какое-либо значение. Кто будет выполнять очистку после частично сконструированного класса?
В объектном Паскале существует следующее решение. Если возникла ошибка при исполнении constructor-а, то сразу вызывается destructor. Именно по этой причине этот destructor должен быть "дубовым", т.е. сработать в любом случае, даже на частично сконструированном классе. Обычно это не сложно, если освобождать всё безопасным образом, например, с помощью FreeAndNil
.
Также можно полагаться на факт, что перед вызовом constructor-а вся память гарантированно обнуляется. Таким образом, при создании все ссылки внутри класса являются nil
, а числа равны 0 и т.п.
Т.е. следующий код сработает без утечек памяти:
link:code-samples_russian/exception_in_constructor_test.lpr[role=include]
Интерфейс, так же как и класс, объявляет API, но не определяет его конкретную реализацию. Класс может иметь только один родительский класс, однако может реализовать множество интерфейсов.
Можно выполнить приведение типа класса к любому интерфейсу, которому он соответствует, и потом вызывать его методы через этот интерфейс. Это позволяет унифицированным образом обрабатывать множество классов, которые не наследуют друг друга, однако имеют подобный функционал. Такой подход полезен в случае, если недостаточно простого механизма наследования.
Интерфейсы CORBA в Object Pascal работают в значительной степени так, как и интерфейсы в Java (https://docs.oracle.com/javase/tutorial/java/concepts/interface.html) или в C# (https://msdn.microsoft.com/en-us/library/ms173156.aspx).
link:code-samples_russian/interfaces_corba_test.lpr[role=include]
- Почему интерфейсы названы "CORBA"?
-
Название CORBA крайне неудачное. Куда лучший термин был бы обычный интерфейс. Такие интерфейсы являются "чистой особенностью языка". Их можно использовать, если возникает необходимость приведения типов различных классов в виде одинакового интерфейса, поскольку у них одинаковая API.
Не смотря на то, что эти интерфейсы могут использоваться совместно с технологией CORBA (Common Object Request Broker Architecture) (см. https://en.wikipedia.org/wiki/Common_Object_Request_Broker_Architecture), они не имеют к ней никакого отношения.
- Требуется ли задавать
{$interfaces corba}
? -
Да, поскольку по умолчанию будут созданы COM интерфейсы. Последнее можно указать явно с помощью директивы
{$interfaces com}
, но не обязательно, так как это случай по умолчанию.Лично я не советую использовать COM интерфейсы. Особенно в случае, если Вам знакомы интерфейсы из других языков программирования. Интерфейсы CORBA в Паскале — это именно то, что ожидается от интерфейсов, они идентичны интерфейсам в C# и Java. При этом COM интерфейсы привносят дополнительные "особенности", которые, скорее всего, не понадобятся.
Обратите внимание, что
{$interfaces xxx}
определяет только интерфейсы, родительский интерфейс которых явно не указан (лишь используется ключевое словоinterface
, а неinterface(ISomeAncestor)
). Если интерфейс имеет родительский интерфейс, он имеет такой же тип, как и родительский интерфейс и не зависит от директивы{$interfaces xxx}
. - Что такое COM интерфейсы?
-
Понятие COM интерфейс является синонимом понятия интерфейс, наследующий некоторый интерфейс
IUnknown
. Такое наследование отIUnknown
:-
Требует, чтобы классы определяли методы
_AddRef
и_ReleaseRef
. Правильная имплементация этих методов позволит управлять классом с помощью подсчёта ссылок (reference-counting). -
Добавляет метод
QueryInterface
. -
Позволяет взаимодействовать с технологией COM (Компонентная модель объектов).
-
- Почему я не советую использовать COM интерфейсы?
-
Дополнительные возможности, привнесённые COM интерфейсами с моей точки зрения проблематичны. Не поймите превратно — идея reference-counting очень хороша. Однако переплетение такого функционала с интерфейсами (вместо того, чтобы делать их "поперечными" свойствами), на мой взгляд, является очень грязным. И явно не соответствует задачам, которые я встречал в своей практике.
-
Иногда возникает необходимость передать обычные классы (не имеющими ничего общего) через обычный интерфейс.
-
Иногда возникает желание управлять памятью с помощью технологии reference-counting.
-
Возможно когда-нибудь лично мне тоже пригодится COM технология.
Но всё это - ничем не связанные задачи. Переплетать их в единой особенности языка, на мой личный взгляд, нехорошо. И это не просто вопрос эстетики, такой подход причиняет реальные проблемы: механизм reference-counting интерфейса COM, даже если отключён с помощью специальных имплементаций
_AddRef
и_ReleaseRef
, всё равно может привести к ошибкам. Придётся быть внимательным и следить, чтобы нигде не осталась временная ссылка на интерфейс после освобождения экземпляра класса. Чуть подробнее речь об этом пойдёт дальше.Именно по этому, мой совет: всегда использовать интерфейс в стиле CORBA и соответствующую
{$interfaces corba}
директиву в современном коде, который работает с интерфейсами. На мой взгляд, COM интерфейсы это некоторое "недоразумение" языка.Однако, чтобы быть честными, чуть дальше будет детально идти речь и о COM интерфейсах.
-
- Можно ли использовать reference-counting совместно с интерфейсом CORBA?
-
Естественно. Необходимо лишь добавить методы
_AddRef
/_ReleaseRef
. Нет необходимости наследовать интерфейсIUnknown
. Впрочем, в большинстве случаев, если возникает необходимость использовать reference-counting в интерфейсах, можно просто использовать COM интерфейсы.
GUID-ы это на первый взгляд случайная последовательность букв и цифр ['{ABCD1234-…}']
которую можно увидеть при каждом объявлении интерфейса. В действительности, они и являются случайными. Но, к сожалению, в них есть необходимость. Никакого особого смысла в них не вкладывается (если не планируется интеграция с технологиями COM или CORBA). Однако, для правильного исполнения программы они обязательны. И пусть не сбивает с толку компилятор, который, увы, позволяет создавать интерфейсы без GUID-ов.
Без присваивания (уникального) GUID-а, все интерфейсы будут идентичными для оператора is
. Таким образом, он всегда будет возвращать true
если данный класс поддерживает любой из используемых интерфейсов. "Волшебная" функция Supports(ObjectInstance, IMyInterface)
работает несколько лучше в данном случае, поскольку выдаст ошибку компиляции для интерфейсов без GUID. Это касается и CORBA, и COM интерфейсов, для версии FPC 3.0.0.
Таким образом, необходимо обязательно объявлять GUID для каждого интерфейса. Можно использовать встроенный генератор GUID-ов Lazarus GUID generator (горячая клавиша Ctrl + Shift + G
в режиме редактирования). Либо воспользоваться он-лайн сервисом, например https://www.guidgenerator.com/.
А можно вообще написать свой инструмент, использующий CreateGUID
и GUIDToString
функции из RTL. Например, следующим образом:
link:code-samples/gen_guid.lpr[role=include]
Использование COM интерфейсов даёт две дополнительных возможности:
-
Интеграция с технологией COM (технология, которая используется в Windows, также доступна в Unix-системах через XPCOM, который применяется Mozilla),
-
Подсчёт ссылок - reference counting (что позволяет автоматически освобождать экземпляр класса, когда все ссылки на его интерфейс уходят из поля переменных).
На мой взгляд, неправильно переплетать интерфейсы с такими возможностями. Это усложняет использование интерфейсов для того, для чего они предназначены: когда у многих классов один и тот же API, однако они не могут происходить от одного родительского класса. Используя же COM интерфейс, следует помнить о механизме автоматического уничтожения и его отношении к технологии COM.
На практике это означает следующее:
-
В классе необходимо создать "волшебные" методы
_AddRef
,_Release
иQueryInterface
. Или наследовать класс, который уже имеет их. Конкретная имплеметация этих методов позволяет включить или отключить такую возможность COM интерфейсов, как reference-counting. Впрочем, отключать её достаточно опасно — см. следующий подраздел.-
Стандартный класс
TInterfacedObject
имеет эти методы выполненными в таком виде, чтобы включить reference-counting. -
Стандартный класс
TComponent
имеет эти методы выполненными в таком виде, чтобы выключить reference-counting. В Castle Game Engine предоставлены дополнительные классы, от которых можно выполнять наследование:TNonRefCountedInterfacedObject
иTNonRefCountedInterfacedPersistent
для этих целей, см. детальнее: https://github.com/castle-engine/castle-engine/blob/0519585abc13e8386cdae5f7dfef6f9659dc9b57/src/base/castleinterfaces.pas.
-
-
Необходимо быть аккуратным при освобождении класса, когда на него могут ссылаться некоторые переменные интерфейса. Поскольку интерфейсы освобождаются с помощью виртуального метода (потому что он может использовать reference-counting, даже если
_AddRef
написан таким образом, чтобы отключить эту возможность), то нельзя освобождать низлежащий экземпляр класса из-за того, что какая-либо переменная интерфейса может на него указывать. См. раздел "7.7 Reference counting" в руководстве FPC (http://freepascal.org/docs-html/ref/refse47.html).
Чтобы безопасно использовать COM интерфейсы необходимо
-
осознавать факт, что в них используется reference-counting,
-
наследовать соответствующие классы от
TInterfacedObject
-
и избегать прямого обращения к экземпляру класса, вместо чего всегда использовать экземпляр через интерфейс, оставляя алгоритму reference-counting управление освобождением памяти.
Ниже представлен пример использования такого интерфейса:
link:code-samples_russian/interfaces_com_with_ref_counting.lpr[role=include]
Как уже было упомянуто в прошлом разделе, в классах, наследующих TComponent
(либо подобные классы, такие как TNonRefCountedInterfacedObject
и TNonRefCountedInterfacedPersistent
) отключено reference-counting для COM интерфейсов. Это позволяет использовать COM интерфейсы и при этом даёт возможность освобождать классы вручную.
Всё же, необходимо быть аккуратным в этом случае, чтобы не освободить класс, когда какая-либо переменная интерфейса ссылается на него и не забывать, что каждая операция приведения типа в виде Cx as IMyInterface
также создаёт временную переменную интерфейса, которая может существовать вплоть до конца текущей процедуры. Этот пример иллюстрирует процедуру UseInterfaces
, которая освобождает классы вне этой процедуры (когда мы можем быть уверены, что временная переменная интерфейса вышла из текущего поля переменных).
Чтобы избежать этих неудобств, лучше использовать интерфейсы CORBA, если в данной программе нет необходимости параллельного использования reference-counting и интерфейсов.
link:code-samples_russian/interfaces_com_test.lpr[role=include]
Этот раздел касается как CORBA, так и COM интерфейсов. Впрочем, также отмечены некоторые особенности CORBA интерфейсов.
-
Приведение типа интерфейса через оператор
as
выполняет проверку в режиме исполнения. Рассмотрим пример:UseThroughInterface(Cx as IMyInterface);
Этот вариант скомпилируется для всех экземпляров
C1
,C2
,C3
из примера в предыдущем подразделе. При выполнении программы возникнет ошибка дляC3
, у которого не описанIMyInterface
.Использование оператора
as
работает правильно независимо от того, объявлен лиCx
как экземпляр класса (например,TMyClass2
) или как интерфейс (например,IMyInterface2
).However, it is not allowed for CORBA interfaces.
-
Однако, возможно привести тип данного экземпляра самым непосредственным образом:
UseThroughInterface(Cx);
В таком случае проверка выполняется в момент компиляции. Такой код скомпилируется для
C1
иC2
(которые определены, как классы, использующиеIMyInterface
), но не скомпилируется дляC3
.По большому счёту, такое приведение типа работает как для обычных классов. В случае, если требуется экземпляр класса
TMyClass
, всегда можно использовать переменную, объявленную какTMyClass
, которой подойдёт любой наследникTMyClass
. Точно такая же ситуация и для интерфейсов. Не требуется выполнять явные приведения типов в такой ситуации. -
Можно также выполнить приведение типа
IMyInterface(Cx)
следующим образом:UseThroughInterface(IMyInterface(Cx));
Обычно, такой синтаксис приведения типа является небезопасным, не проверяемым. Если привести ошибочный тип интерфейса подобным образом, могут возникнуть ошибки, как в случае приведения типа класса к классу, или _интерфейса к интерфейсу_с помощью данного синтаксиса.
Можно сделать небольшую оговорку: если
Cx
объявлен как class (напримерTMyClass2
), то такая операция приведения типа должна быть верной при компиляции. Иными словами, приведение типа класса к интерфейсу таким образом - безопасно и быстро (проверка выполняется при компиляции).
Можете всё это попробовать, поиграв со следующим примером:
link:code-samples_russian/interface_casting.lpr[role=include]
Copyright Michalis Kamburelis.
Исходные файлы этого документа в формате AsciiDoc можно скачать по адресу: https://github.com/michaliskambi/modern-pascal-introduction. Автор будет рад любым пожеланиям, исправлениям, расширениям материала, доработкам и pull request-ам :). С автором можно связаться через профиль GitHub либо по e-mail: [email protected]. Домашняя страничка автора: https://michalis.xyz/.
Этот документ можно свободно распространять и изменять согласно лицензи идентичной лицензии Wikipedia, см. https://en.wikipedia.org/wiki/Wikipedia:Copyrights:
-
Creative Commons Attribution-ShareAlike 3.0 Unported License (CC BY-SA)
Либо
-
GNU Free Documentation License (GFDL) (unversioned, with no invariant sections, front-cover texts, or back-cover texts) .
Благодарю за прочтение!
Перевод на русский выполнен: Александр Скворцов и Евгений Лоза 2016-2019
Благодарность за помощь в поиске и исправлении ошибок и очепяток: vitaly_l и pupsik @ freepascal.ru