Регистрация | Войти
Lisp — программируемый язык программирования
Предыдущая Оглавление Следующая

17. Переходим к объектам: Классы

Если обобщенные функции являются глаголами объектной системы, то классы являются существительными. Как я упоминал в предыдущей главе, все значения в программах на Common Lisp являются экземплярами какого-то из классов. Более того, все классы образуют иерархию на вершине которой находится класс T.

Иерархия классов состоит из двух основных семейств классов: встроенных и определенных пользователем. Классы, которые представляют типы данных, которые мы изучали до сих пор – такие как INTEGER, STRING и LIST, являются встроенными. Они находятся в отдельном разделе иерархии классов, организованные соответствующими связями дочерних и родительских классов, и для работы с ними используются функции, которые я описывал на протяжении всей книги. Вы не можете унаследовать от этих классов, но как вы увидели в предыдущем разделе, вы можете определить специализированные методы для них, эффективно расширяя поведения этих классов.1)

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

DEFCLASS

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

Класс, как тип данных, состоит из трех частей: имени, отношения к другим классам и имен слотов.2) Базовая форма DEFCLASS выглядит достаточно просто.

(defclass name (direct-superclass-name*)
  (slot-specifier*)
)

Что такое классы, определенные пользователем?

"Определенные пользователем классы" – термин не из стандарта языка. Определёнными пользователем классами я называю подклассы класса STANDARD-OBJECT, а также классы, у которых метакласс – STANDARD-CLASS. Но поскольку я не собираюсь говорить о способах определения классов, которые не наследуют STANDARD-OBJECT и чей метакласс – это не STANDARD-CLASS, вам можно не обращать на это внимания. Определённые пользователем – не идеальный термин, потому что реализация может определять некоторые классы таким же способом. Но ещё большей путаницей будет называть эти классы стандартными, поскольку встроенные классы (например, INTEGER и STRING) тоже стандартные, если не сказать больше, потому что они определены стандартом языка, но они не расширяют (не наследуют) STANDARD-OBJECT. Чтобы ещё больше запутать дело, пользователь может также определять классы, не наследующие STANDARD-OBJECT. В частности, макрос DEFSTRUCT тоже определяет новые классы. Но это во многом для обратной совместимости – DEFSTRUCT появился раньше, чем CLOS и был изменен, чтоб определять классы, когда CLOS добавлялся в язык. Но создаваемые им классы достаточно ограничены по сравнению с классами, созданными с помощью DEFCLASS. Итак, я буду обсуждать только классы, создаваемые с помощью DEFCLASS, которые используют заданный по умолчанию метакласс STANDARD-CLASS и, за неимением лучшего термина, назову их "определёнными пользователем классами".

Также как с функциями и переменными, вы можете использовать в качестве имени класса любой символ.3) Имена классов находятся в собственном пространстве имен, отдельно от имен функций и переменных, так что вы можете задать для класса то же самое имя, что и существующие функция и переменная. Вы будете использовать имя класса в качестве аргумента функции MAKE-INSTANCE, которая создает новые экземпляры классов, определенных пользователем.

Опция direct-superclass-names используется для указания имен классов, от которых будет проводиться наследование данного класса. Если ни одного класса не указано, то он будет унаследован от STANDARD-OBJECT. Все классы, указанные в данной опции, должны быть классами, определенными пользователем, чтобы быть увереным, что каждый новый класс происходит от STANDARD-OBJECT. STANDARD-OBJECT является подклассом T, так что все классы, определенные пользователем, являются частью одной иерархии классов, которая также содержит все встроенные классы.

На время отвлекаясь от упоминания спецификаторов слотов, запись DEFCLASS для некоторых из классов, которые мы использовали в предыдущей главе, может выглядеть следующим образом:

(defclass bank-account () ...)

(defclass checking-account (bank-account) ...)

(defclass savings-account (bank-account) ...)

В разделе "Множественное наследование" я объясню, что означает указание более чем одного суперкласса в списке опции direct-superclass-names.

Спецификаторы слотов

Большая часть DEFCLASS состоит из списка спецификаторов слотов. Каждый спецификатор определяет слот, который будет частью экземпляра класса. Каждый слот в экземпляре является местом, которое может хранить значение, к которому можно получить доступ через функцию SLOT-VALUE. SLOT-VALUE в качестве аргументов принимает объект и имя слота и возвращает значение нужного слота в данном объекте. Эта функция может использоваться вместе с SETF для установки значений слота в объекте.

Класс также наследует спецификаторы слотов от своих суперклассов, так что набор слотов, присутствующих в любом объекте, является объединением всех слотов, указанных в форме DEFCLASS для класса, а также указанных для всех его суперклассов.

По минимуму, спецификатор слота указывает его имя, так что спецификатор может быть простым именем. Например, вы можете определить класс bank-account с двумя слотами – customer-name и balance, например, вот так:

(defclass bank-account ()
  (customer-name
   balance
)
)

Каждый экземпляр этого класса содержит два слота: один для хранения имени клиента, а второй – для хранения текущего баланса счета. Используя данное определение вы можете создать новые объекты bank-account с помощью MAKE-INSTANCE.

(make-instance 'bank-account) ==> #<BANK-ACCOUNT @ #x724b93ba>

Аргументом MAKE-INSTANCE является имя класса, а возвращаемым значением – новый объект.4) Печатное представление объекта определяется обобщенной функцией PRINT-OBJECT. В этом случае, подходящим методом будет тот, который предоставляется реализацией и специализированный для STANDARD-OBJECT. Поскольку не каждый объект может быть выведен таким образом, чтобы потом быть считанным назад, то метод печати для STANDARD-OBJECT использует синтаксис #<>, который заставит процедуру чтения выдать ошибку, если он попытается прочитать его. Оставшаяся часть представления зависит от реализации, но обычно оно похоже на результат, приведенный выше, включая имя класса и некоторое значение, например, адрес объекта в памяти. В главе 23 вы увидите пример того, как определить метод для PRINT-OBJECT чтобы некоторые классы можно было вывести в более информативной форме.

Используя данное определение bank-account, новые объекты будут создаваться со слотами, которые не связаны со значениями. Любая попытка получить значение для несвязанного значения приведет к выдаче ошибки, так что вы должны задать значение до того, как вы будете считывать значения.

(defparameter *account* (make-instance 'bank-account))  ==> *ACCOUNT*
(setf (slot-value *account* 'customer-name) "John Doe") ==> "John Doe"
(setf (slot-value *account* 'balance) 1000) ==> 1000

Теперь вы можете получать значения слотов.

(slot-value *account* 'customer-name) ==> "John Doe"
(slot-value *account* 'balance) ==> 1000

Инициализация объекта

Поскольку мы мало что можем сделать с объектом, который имеет пустые слоты, было бы хорошо иметь возможность создавать объекты с инициализированными слотами. Common Lisp предоставляет три способа управления начальными значениями слотов. Первые два требуют добавления опций в спецификаторы слотов в DEFCLASS: с помощью опции :initarg вы можете указать имя, которое потом будет использоваться как именованный параметр при вызове MAKE-INSTANCE и переданное значение будет сохранено в слоте. Вторая опция – :initform, позволяет вам указать выражение на Lisp, которое будет использоваться для вычисления значения, если при вызове MAKE-INSTANCE не был передан аргумент :initarg . В заключение, для полного контроля за инициализацией объекта, вы можете определить метод для обобщенной функции INITIALIZE-INSTANCE, которую вызывает MAKE-INSTANCE.5)

Спецификатор слота, который включает опции, такие как :initarg или :initform, записывается как список, начинающийся с имени слота, за которым следуют опции. Например, если вы измените определение bank-account таким образом, чтобы позволить передавать имя клиента и начальный баланс при вызове MAKE-INSTANCE, а также чтобы установить для баланса начальное значение равное нулю, вы должны написать:

(defclass bank-account ()
  ((customer-name
    :initarg :customer-name
)

   (balance
    :initarg :balance
    :initform 0
)
)
)

Теперь вы можете одновременно создавать счет и указывать значения слотов.

(defparameter *account*
  (make-instance 'bank-account :customer-name "John Doe" :balance 1000)
)

(slot-value *account* 'customer-name) ==> "John Doe"
(slot-value *account* 'balance) ==> 1000

Если вы не передадите аргумент :balance при вызове MAKE-INSTANCE, то вызов SLOT-VALUE для слота balance будет получен вычислением формы, указанной опцией :initform. Но, если вы не передадите аргумент :customer-name, то слот customer-name будет пустой, и попытка считывания значения из него, приведет к выдаче ошибки.

(slot-value (make-instance 'bank-account) 'balance)       ==> 0
(slot-value (make-instance 'bank-account) 'customer-name) ==> Ошибка (error)

Если вы хотите убедиться, что имя клиента было задано при создании счета, то вы можете выдать ошибку в начальном выражении (initform), поскольку оно будет вычислено только если начальное значение (initarg) не было задано. Вы также можете использовать начальные формы, которые создают разные значения при каждом запуске – начальное выражение вычисляется заново для каждого объекта. Для эксперементирования с этими возможностями, вы можете изменить спецификатор слота customer-name и добавить новый слот, account-number, который инициализируется значением увеличивающегося счетчика.

(defvar *account-numbers* 0)

(defclass bank-account ()
  ((customer-name
    :initarg :customer-name
    :initform (error "Must supply a customer name.")
)

   (balance
    :initarg :balance
    :initform 0
)

   (account-number
    :initform (incf *account-numbers*)
)
)
)

В большинстве случаев, комбинации опций :initarg и :initform будет достаточно для нормальной инициализации объекта. Однако, хотя начальное выражение может быть любым выражением Lisp, оно не имеет доступа к инициализируемому объекту, так что оно не может инициализировать один слот, основываясь на значении другого. Для выполнения такой задачи вам необходимо определить метод для обобщенной функции INITIALIZE-INSTANCE.

Основной метод INITIALIZE-INSTANCE, специализированный для STANDARD-OBJECT берет на себя заботу об инициализации слотов, основываясь на данных, заданных опциями :initarg и :initform. Поскольку вы не захотите вмешиваться в этот процесс, то наиболее широко применяемым способом является определение метода :after, специализированного для вашего класса.6) Например, предположим, что вы хотите добавить слот account-type, который должен быть установлен в значение :gold, :silver или :bronze, основываясь на начальном балансе счета. Вы можете изменить определение класса на следующее, добавляя слот account-type без каких либо опций:

(defclass bank-account ()
  ((customer-name
    :initarg :customer-name
    :initform (error "Must supply a customer name.")
)

   (balance
    :initarg :balance
    :initform 0
)

   (account-number
    :initform (incf *account-numbers*)
)

   account-type
)
)

После этого вы можете определить метод :after для INITIALIZE-INSTANCE, который установит значение слота account-type, основываясь на значении, которое было сохранено в слоте balance.7)

(defmethod initialize-instance :after ((account bank-account) &key)
  (let ((balance (slot-value account 'balance)))
    (setf (slot-value account 'account-type)
          (cond
            ((>= balance 100000) :gold)
            ((>= balance 50000) :silver)
            (t :bronze)
)
)
)
)

Указание &key в списке параметров требуется обязательно, чтобы сохранить список параметров соответствующим списку параметров обобщенной функции– список параметров, указанный для функции INITIALIZE-INSTANCE включает &key чтобы позволить отдельным методам передавать собственные именованные параметры, но при этом, он не требует указания конкретных названий. Таким образом, каждый метод должен указывать &key, даже если он не указывает ни одного именованного параметра.

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

(defmethod initialize-instance :after ((account bank-account)
                                       &key opening-bonus-percentage
)

  (when opening-bonus-percentage
    (incf (slot-value account 'balance)
          (* (slot-value account 'balance) (/ opening-bonus-percentage 100))
)
)
)

Путем определения метода INITIALIZE-INSTANCE, вы делаете :opening-bonus-percentage допустимым аргументом функции MAKE-INSTANCE при создании объекта bank-account.

CL-USER> (defparameter *acct* (make-instance
'bank-account
:customer-name "Sally Sue"
:balance 1000
:opening-bonus-percentage 5))
*ACCT*
CL-USER> (slot-value *acct* 'balance)
1050

Функции доступа

MAKE-INSTANCE и SLOT-VALUE дают вам возможности для создания и работы с экземплярами ваших классов. Все остальные операции могут быть реализованы в терминах этих двух функций. Однако, как знает всякий, знакомый с принципами правильного объектно-ориентированного программирования, прямой доступ к слотам (полям или переменным-членам) объекта может привести к получению уязвимого кода. Проблема заключается в том, что прямой доступ к слотам делает ваш код слишком связанным с конкретной структурой классов. Например, предположим, что вы решили изменить определение bank-account таким образом, что вместо хранения текущего баланса в виде числа, вы храните его в виде списка списаний и помещений денег на счет, вместе с датами этих операций. Код, который имеет прямой доступ к слоту balance скорее всего будет сломан, если вы измените определение класса, удалив слот или храня список в данном слоте. С другой стороны, если вы определить функцию balance, которая осуществляет доступ к слоту, то вы можете позже переопределить ее, чтобы сохранить ее поведение, даже если изменится внутреннее представление данных. И код, который использует такую функцию будет продолжать нормально работать не требуя внесения изменений.

Другим преимуществом использования функций доступа вместо прямого доступа к слотам через SLOT-VALUE является то, что их использование позволяет вам ограничить возможность внешней модификации слота.8) Для пользователей класса bank-account может быть удобным использование функций доступа для получения текущего баланса, но вы можете захотеть, чтобы все изменения баланса производились через другие предоставляемые вами функции, такие как deposit и withdraw. Если клиент знает, что он сможет работать с объектами только через определенный набор функций, то вы можете предоставить ему функцию balance, но сделать так, чтобы для нее нельзя было выполнить SETF, чтобы баланс был доступен только для чтения.

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

Определение функции, которая читает содержимое слота balance является тривиальным.

(defun balance (account)
  (slot-value account 'balance)
)

Однако, если вы знаете, что вы будете определять подклассы для bank-account, то может быть хорошей идеей определение balance в качестве обобщенной функции. Таким образом вы можете определить разные методы для balance для некоторых подклассов, или расширить ее возможности с помощью вспомогательных методов. Так что вместо предыдущего примера можете написать следующее:

(defgeneric balance (account))

(defmethod balance ((account bank-account))
  (slot-value account 'balance)
)

Как я только что обсуждал, вы не хотите, чтобы пользователь имел возможность устанавливать баланс напрямую, но для других слотов, таких как customer-name, вы также можете захотеть предоставить функцию для установки их значений. Наиболее понятным способом будет определение такой функции как SETF-функции.

SETF-функция является способом расширения функциональности SETF, определяя новый вид места (place), для которого известно как устанавливать его значение. Имя SETF-функции является списком из двух элементов, где первый элемент является символом setf, а второй – другим символом, обычно именем функции, которая используется для доступа к месту, значение которого будет устанавливать функция SETF. SETF-функция может получать любое количество аргументов, но первым аргументом всегда является значение, присваиваемое выбранному месту.9) Например, вы можете определить SETF-функцию для установки значения слота customer-name в классе bank-account следующим образом:

(defun (setf customer-name) (name account)
  (setf (slot-value account 'customer-name) name)
)

После вычисления этого определения, выражения, подобные этому:

(setf (customer-name my-account) "Sally Sue")

будут компилироваться как вызов SETF-функции, которую вы только что определили с значением "Sally Sue" в качестве первого аргумента, и значением my-account в качестве второго аргумента.

Конечно, также как с функциями чтения, вы вероятно захотите, чтобы ваша SETF-функция была обобщенной, так что вы должны ее определить примерно так:

(defgeneric (setf customer-name) (value account))

(defmethod (setf customer-name) (value (account bank-account))
  (setf (slot-value account 'customer-name) value)
)

И конечно, вы также можете определить функцию чтения для customer-name.

(defgeneric customer-name (account))

(defmethod customer-name ((account bank-account))
  (slot-value account 'customer-name)
)

Это позволит вам писать следующим образом:

(setf (customer-name *account*) "Sally Sue") ==> "Sally Sue"
(customer-name *account*) ==> "Sally Sue"

Нет ничего сложного в написании этих функций доступа, но написание этих функций вручную просто не соответствует The Lisp Way. Так что DEFCLASS поддерживает три опции для слотов, которые позволяют вам автоматически создавать функции чтения и записи значений отдельных слотов.

Опция :reader указывает имя, которое будет использоваться как имя обобщенной функции, которая принимает объект в качестве своего единственного аргумента. Когда вычисляется DEFCLASS, то создается соответствующая обобщенная функция (если она еще не определена конечно). После этого для данной обобщенной функции создается метод, специализированный для нового класса и возвращающий значение слота. Имя функции может быть любым, но обычно используют то же самое имя, что и имя самого слота. Так что вместо явного задания обобщенной функции balance и метода для нее, как это было показано раньше, вы можете просто изменить спецификатор слота balance в определении класса bank-account на следующее:

(balance
 :initarg :balance
 :initform 0
 :reader balance
)

Опция :writer используется для создания обобщенной функции и метода для установки значения слота. Создаваемая функция и метод следуют требованиям для SETF-функции, получая новое значение как первый аргумент, и возвращая его в качестве результата, так что вы можете определить SETF-функцию задавая имя, такое как (setf customer-name). Например, вы можете определить методы чтения и записи для слота customer-name, просто изменяя спецификатор слота на следующее определение:

(customer-name
 :initarg :customer-name
 :initform (error "Must supply a customer name.")
 :reader customer-name
 :writer (setf customer-name)
)

Поскольку достаточно часто требуется определение обеих функций доступа, то DEFCLASS также имеет опцию :accessor, которая создает и функцию чтения, и соответствующую SETF-функцию. Так что вместо предыдущего примера можно написать следующим образом:

(customer-name
 :initarg :customer-name
 :initform (error "Must supply a customer name.")
 :accessor customer-name
)

В заключение, опишу еще одну опцию, о которой вы должны знать: опция :documentation позволяет вам задать строку, которая описывает данный слот. Собирая все в кучу и добавляя методы чтения для слотов account-number и account-type, определение DEFCLASS для класса bank-account будет выглядеть примерно так:

(defclass bank-account ()
  ((customer-name
    :initarg :customer-name
    :initform (error "Must supply a customer name.")
    :accessor customer-name
    :documentation "Customer's name"
)

   (balance
    :initarg :balance
    :initform 0
    :reader balance
    :documentation "Current account balance"
)

   (account-number
    :initform (incf *account-numbers*)
    :reader account-number
    :documentation "Account number, unique within a bank."
)

   (account-type
    :reader account-type
    :documentation "Type of account, one of :gold, :silver, or :bronze."
)
)
)

''WITH-SLOTS'' и ''WITH-ACCESSORS''

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

Это как раз тот случай, для которого и предназначен макрос SLOT-VALUE; однако, он также достаточно многословен. Если функция или метод осуществляют доступ к одному и тому же слоту несколько раз, то исходный код будет засорен вызовами функций доступа и SLOT-VALUE. Например, даже достаточно простой метод, такой как следующий пример, который вычисляет пеню для bank-account если баланс снижается ниже некоторого минимума, будет засорен вызовами balance и SLOT-VALUE:

(defmethod assess-low-balance-penalty ((account bank-account))
  (when (< (balance account) *minimum-balance*)
    (decf (slot-value account 'balance) (* (balance account) .01))
)
)

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

(defmethod assess-low-balance-penalty ((account bank-account))
  (when (< (slot-value account 'balance) *minimum-balance*)
    (decf (slot-value account 'balance) (* (slot-value account 'balance) .01))
)
)

Два стандартных макроса – WITH-SLOTS и WITH-ACCESSORS, могут помочь избавиться от этого мусора. Оба макроса создают блок кода, в которых могут использоваться простые имена переменных для обращения к слотам определенного объекта. WITH-SLOTS предоставляет прямой доступ к слота, также как при использовании SLOT-VALUE, в то время как WITH-ACCESSORS предоставляет сокращенный способ вызова функций доступа.

Базовая форма WITH-SLOTS выглядит следующим образом:

(with-slots (slot*) instance-form
  body-form*
)

Каждый элемент списка slot может быть либо именем слота,которое также является именем переменной, либо списком из двух элементов, где первый аргумент является именем, которое будет использоваться как переменная, а второй – именем соответствующего слота. Выражение instance-form вычисляется один раз для получения объекта, к слотам которого будет производиться доступ. Внутри тела макроса, каждое вхождение имени переменной преобразуется в вызов SLOT-VALUE с использованием объекта и имени слота в качестве аргументов.10) Таким образом, вы можете переписать assess-low-balance-penalty вот так:

(defmethod assess-low-balance-penalty ((account bank-account))
  (with-slots (balance) account
    (when (< balance *minimum-balance*)
      (decf balance (* balance .01))
)
)
)

или используя списочную запись, вот так:

(defmethod assess-low-balance-penalty ((account bank-account))
  (with-slots ((bal balance)) account
    (when (< bal *minimum-balance*)
      (decf bal (* bal .01))
)
)
)

Если вы определили balance с использованием опции :accessor, а не :reader, то вы также можете использовать макрос WITH-ACCESSORS. Форма WITH-ACCESSORS такая же как WITH-SLOTS за тем исключением, что каждый элемент списка слотов является списком из двух элементов, содержащих имя переменной и имя функции доступа. Внутри тела WITH-ACCESSORS, ссылка на одну из переменных, аналогична вызову соответствующей функции доступа. Если функция доступа разрешает выполнение SETF, то тоже самое возможно и для переменной.

(defmethod assess-low-balance-penalty ((account bank-account))
  (with-accessors ((balance balance)) account
    (when (< balance *minimum-balance*)
      (decf balance (* balance .01))
)
)
)

Первое вхождение balance является именем переменной, а второе – именем функции доступа; они не обязательно должны быть одинаковыми. Например, вы можете написать метод для слияния двух счетов, используя два вызова WITH-ACCESSORS, для каждого из счетов.

(defmethod merge-accounts ((account1 bank-account) (account2 bank-account))
  (with-accessors ((balance1 balance)) account1
    (with-accessors ((balance2 balance)) account2
      (incf balance1 balance2)
      (setf balance2 0)
)
)
)

Выбор между использованием WITH-SLOTS и WITH-ACCESSORS примерно таков, как и выбор между использованием SLOT-VALUE и функций доступа: низкоуровневый код, которые обеспечивает основную функциональность класса, может использовать SLOT-VALUE или WITH-SLOTS для работы со слотами напрямую, если функции доступа не поддерживают нужный стиль работы, или если хочется явно избежать использования вспомогательных методов, которые могут быть определены для функций доступа. Но в общем вы должны использовать функции доступа или WITH-ACCESSORS, если только у вас не имеются конкретные причины не делать этого.

Слоты, выделяемые для классов

Заключительной опцией, которую вам необходимо знать, является опция :allocation. Значением опции :allocation может быть либо :instance, либо :class, и по умолчанию оно равно :instance, если оно не было явно указано. Когда слот имеет значение опции равное :class, то слот имеет только одно значение, которое сохраняется внутри класса и используется всеми экземплярами.

Однако доступ к слотам со значением :class производится также как и для слотов со значением :instance – доступ производится с помощью SLOT-VALUE или функции доступа, что значит, что вы можете получить доступ только через экземпляр класса, хотя это значение не хранится в этом экземпляре. Опции :initform и :initarg имеют точно такой же эффект, за тем исключением, что начальное выражение вычисляется один раз, при определении класса, а не при создании экземпляра. С другой стороны, передача начальных аргументов MAKE-INSTANCE установит значение, затрагивая все экземпляры данного класса.

Поскольку вы не можете получить слот, выделенный для класса, не имея экземпляра класса, то такие слоты не являются полным аналогам статическим членам в таких языках как Java, C++ и Python.11) В значительной степени, слоты выделенные для класса в основном используются для уменьшения потребляемой памяти; если вы создаете много экземпляров класса и они все имеют ссылку на один и тот же объект (например, список разделяемых ресурсов), то вы можете сократить использование памяти путем объявления такого слота, выделяемым для класса, а не для экземпляра.

Слоты и наследование

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

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

Common Lisp решает эту проблему путем слияния всех спецификаторов с одним и тем же именем из нового класса и всех его суперклассов для создания отдельных спецификаторов для каждого уникального имени слота. При слиянии спецификатор, разные опции спецификаторов слотов рассматриваются по разному. Например, поскольку слот может иметь только одно значение по умолчанию, то если несколько классов указывают опцию :initform, то новый класс будет использовать эту опцию из наиболее специализированного класса. Это позволяет подклассам указывать собственные значение по умолчанию, а не те, которые были унаследованы.

С другой стороны, опции :initargs не должны быть взаимоисключающими – каждая опция :initarg создает именованный параметр, который может быть использован для инициализации слота; множественные параметры не приводят к конфликту, так что новый спецификатор слота будет содержать все опции :initargs. Вызывающие MAKE-INSTANCE могут использовать любое из имен, указанных в :initargs для инициализации слота. Если вызывающий указывает несколько именованных аргументов, которые инициализируют один и тот же слот, то используется то, которое стоит левее всех остальных в списке аргументов MAKE-INSTANCE.

Унаследованные опции :reader, :writer и :accessor не включаются в новый спецификатор слота, поскольку методы, созданные при объявлении суперкласса будут автоматически применяться к новому классу. Однако новый класс может создать свои собственные функции доступа, путем объявления собственных опций :reader, :writer или :accessor.

И в заключение, опция :allocation, подобно :initform, определяется наиболее специализированным классом, определяющим данный слот. Таким образом, возможно сделать так, что экземпляры одного класса будут использовать слот с опцией :class, а экземпляры его подклассов могут иметь свои собственные значения опции :instance для слота с тем же именем. А их подклассы, в свою очередь, могут переопределить этот слот с опцией :class, так что все экземпляры данного класса снова будут делить между собой единственный экземпляр слота. В последнем случае, слот, разделяемый экземплярами под-подклассов отличается от слота, разделяемого оригинальным суперклассом.

Например, у вас имеются следующие классы:

(defclass foo ()
  ((a :initarg :a :initform "A" :accessor a)
   (b :initarg :b :initform "B" :accessor b)
)
)


(defclass bar (foo)
  ((a :initform (error "Must supply a value for a"))
   (b :initarg :the-b :accessor the-b :allocation :class)
)
)

При создании экземпляра класса bar, вы можете использовать унаследованный начальный аргумент :a для указания значения для слота a и, в действительности, должны сделать это для того, чтобы избежать ошибок, поскольку опция :initform определенная bar замещает опцию, унаследованную от foo. Для инициализации слота b, вы можете использовать либо унаследованный аргумент :b, либо новый аргумент :the-b. Однако, поскольку для слота b в определении bar указана опция :allocation, то указанное значение будет храниться в слоте, используемом всеми экземплярами bar. Доступ к этому слоту может быть может быть осуществлен либо с помощью метода обобщенной функции b, специализированного для foo, либо с помощью нового метода обобщенной функции the-b, который специализирован для bar. Для доступа к слоту a классов foo или bar, вы продолжите использовать обобщенную функцию a.

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

Множественное наследование

Все классы, которые вы до сих пор видели имели только один суперкласс. Common Lisp также поддерживает множественное наследование – класс может иметь несколько прямых суперклассов, наследуя соответствующие методы и спецификаторы слотов из всех этих классов.

Множественное наследование не вносит кардинальных изменений в механизмы наследования, которые я уже обсуждал – каждый класс определенный пользователем уже имеет несколько суперклассов, поскольку они все наследуются от STANDARD-OBJECT, который унаследован от T, так что по крайней мере имеется два суперкласса. Затруднение, которое вносит множественное наследование заключается в том, что класс может иметь более одного непосредственного суперкласса. Это усложняет понятие специфичности класса, которое используется при построении эффективных методов для обобщенных функции и при слиянии спецификаторов слотов.

Так что, если бы классы могли иметь только один непосредственный суперкласс, то упорядочение классов по специфичности будет тривиальным – класс и все его суперклассы могут быть выстроены в линию начиная с самого класса, за которым следует один прямой суперкласс, за которым следует его суперкласс, и так далее, до класса T. Но когда класс имеет несколько непосредственных суперклассов, то эти классы обычно не связаны друг с другом – конечно, если один класс был подклассом другого, вам не нужно наследовать класс от обоих. В этом случае, правила по которому подклассы более специфичны чем суперклассы недостаточно для упорядочения всех суперклассов. Так что Common Lisp использует второе правило, которое сортирует не относящиеся друг к другу суперклассы по порядку в котором они перечислены в определении непосредственных суперклассов в DEFCLASS – классы, указанные в списке первыми, считаются более специфичными, чем классы, указанные в списке последними. Это правило считается достаточно произвольным, но оно позволяет каждому классу иметь линейный список следования классов, который может использоваться для определения того, какой из суперклассов будет считаться более специфичным чем другой. Однако заметьте, что нет глобального упорядочения классов – каждый класс имеет собственный список следования классов, и одни и те же классы могут стоять на разных позициях в списках списках следования разных классов.

Для того, чтобы увидеть как это работает, давайте добавим новый класс к нашему банковскому приложению: money-market-account. Этот счет объединяет в себе характеристики чекового (checking-account) и сберегательного (savings-account) счетов: клиент может выписывать чеки, но кроме того он получает проценты. Вы можете определить его следующим образом:

(defclass money-market-account (checking-account savings-account) ())

Список следования класса money-market-account будет следующим:

(money-market-account
 checking-account
 savings-account
 bank-account
 standard-object
 t
)

Заметьте, как список удовлетворяет обоим правилам: каждый класс появляется раньше своих суперклассов, а checking-account и savings-account располагаются в порядке, указанном в DEFCLASS.

Этот класс не определяет своих собственных слотов, но унаследует слоты от обоих суперклассов, включая слоты, которые те унаследовали от своих суперклассов. Аналогичным образом, все методы, которые применимы к любому из классов в списке следования, также будут применимы к объекту money-market-account. Поскольку все спецификаторы одинаковых слотов объединяются, то не имеет значения, что money-market-account дважды наследует одни и те же слоты из bank-account.12)

Множественное наследование наиболее просто понять когда суперклассы предоставляют совершенно независимые наборы слотов и методов. Например, money-market-account унаследует слоты и поведение по работе с чеками от checking-account, а слоты и поведение по вычислению процентов – от savings-account. Вам не нужно беспокоиться о списке следования класса для методов и слотов, унаследованных только от одного или другого суперкласса.

Однако, также возможно унаследовать методы для одних и тех же обобщенных функций от различных суперклассов. В этом случае, в игру включается список список следования классов. Например, предположим, что банковское приложение определяет обобщенную функцию print-statement, которая используется для генерации месячных отчетов. Вероятно, что уже будут определены методы print-statement специализированные для. checking-account и savings-account. Оба этих метода будут применимы для экземпляров класса money-market-account, но тот, который специализирован для checking-account будет считаться более специфичным, чем специализированный для savings-account, поскольку checking-account имеет больший приоритет перед savings-account в списке следования классов money-market-account.

Предполагается, что унаследованные методы являются основными методами, и вы не определяли других методов, специализированных для checking-account, которые будут использоваться, если вы выполните print-statement для money-market-account. Однако, это не обязательно даст вам то поведение, которое вы хотите, поскольку вы хотите чтобы отчет для нового счета содержал элементы из отчетов по чековому и сберегательному счетов.

Вы можете изменить поведение print-statement для money-market-accounts несколькими способами. Непосредственным способом является определение основного метода, специализированного для money-market-account. Это даст вам полный контроль за поведением, но вероятно потребует написания кода для опций, которые я буду вскоре обсуждать. Проблема заключается в том, что хотя вы можете использовать CALL-NEXT-METHOD для передачи управления "вверх", следующему методу, а именно, специализированному для checking-account, но не существует способа вызвать конкретный менее специфичный метод, например, специализированный для savings-account. Так что если вы хотите иметь возможность использования кода, который создает часть отчета, специфичную для savings-account, то вам нужно разбить этот код на отдельные функции, которые вы сможете вызвать напрямую из методов print-statement классов money-market-account и savings-account.

Другой возможностью является написание основных методов всех трех классов так, чтобы они вызывали CALL-NEXT-METHOD. Тогда метод, специализированный для money-market-account будет использовать CALL-NEXT-METHOD для вызова метода, специализированного для checking-account. Затем, этот метод вызовет CALL-NEXT-METHOD, что приведет к запуску метода для savings-account, поскольку он будет следующим наиболее специфичным методом в списке следования классов для money-market-account.

Конечно, если вы не хотите полагаться на соглашения о стиле кодирования (что каждый метод будет вызывать CALL-NEXT-METHOD) чтобы убедиться, что все применимые методы будут вызваны в некоторый момент времени, вы должны подумать об использовании вспомогательных методов. В этом случае, вместо определения основного метода print-statement для checking-account и savings-account, вы можете определить их как методы :after, оставляя один основной метод для bank-account. Так что print-statement, вызванный для money-market-account, выдаст базовую информацию о счете, которая будет выведена основным методом, специализированным для bank-account, за которым следуют дополнительные детали, выведенные методами :after специализированными для savings-account и checking-account. И если вы хотите добавить детали, специфичные для money-market-accounts, вы можете определить метод :after, специализированный для money-market-account, который будет выполнен последним.

Преимуществом использования вспомогательных методов является то, что становится понятным какие из методов является ответственным за реализацию обобщенной функции, и какие из них вносят дополнительные детали в работу функции. Недостатком этого подходя является то, что вы не получаете точного контроля за порядком, в котором будут выполняться вспомогательные методы – если вы хотите, чтобы часть отчета, приготовленного для checking-account печаталась перед частью savings-account, то вы должны изменить порядок в котором money-market-account наследуются от этих классов. Но это достаточно трагическое изменение, которое затрагивает другие методы и унаследованные слоты. В общем, если вы обнаружите, что рассматриваете изменение списка непосредственных суперклассов как способ тонкой настройки поведения специфических методов, то вы скорее всего должны сделать шаг назад и заново обдумать ваш подход.

С другой стороны, если вы не заботитесь о порядке наследования, но хотите, чтобы он был последовательным для разных обобщенных функций, то использование вспомогательных методов может быть одним из методов. Например, если в добавление к print-statement вы имеете функцию print-detailed-statement, то вы можете реализовать обе функции используя методы:after для разных подклассов bank-account, и порядок частей и для основного и детального отчета будет одинаков.

Правильный объектно-ориентированный дизайн

Это все о главных возможностях объектной системы Common Lisp. Если у вас имеется большой опыт объектно-ориентированного программирования, вы вероятно увидите как возможности Common Lisp могут быть использованы для реализации правильного объектно-ориентированного дизайна. Однако, если у вас небольшой опыт объектно-ориентированного программирования, то вам понадобиться провести некоторое время чтобы освоиться с объектно-ориентированным мышлением. К сожалению это достаточно большой раздел, находящийся за пределами данной книги. Или, как указано в справочной странице по объектной системе Perl, "Теперь, вам нужно лишь выйти и купить книгу о методологии объектно-ориентированного дизайна, и провести с нею следующие шесть месяцев". Или вы можете продолжить чтение до практических глав, далее в этой книге, где вы увидите несколько примеров того, как эти возможности используются на практике. Однако сейчас, вы готовы к тому, чтобы взять перерыв и перейти от теории объектно-ориентированного программирования к другой теме – как можно полезно использовать мощную, но немного загадочную функцию Common Lisp – FORMAT.

1)Определение новых методов для существующих классов может показаться странным для людей, которые использовали статически типизированные языки, такие как C++ и Java, в которых все методы классов должны быть определены как часть определения класса. А вот программисты, которые имеют опыт программирования на Smalltalk и Objective C не найдут в этой функциональности ничего странного.
2)В других объектно-ориентированных языках слоты могут называться полями, переменными-членами класса или аттрибутами.
3)Также, как и при именовании функции и переменных, это не совсем правда, что вы можете использовать для класса любое имя – вы не можете использовать имена, определенные стандартом. В главе 21 вы увидите как можно избежать таких конфликтов имен.
4)В действительности, аргументом MAKE-INSTANCE может быть либо имя класса, или объект класса, возвращаемый функциями CLASS-OF или FIND-CLASS.
5)Другим способом установки значений слотов, является использование опции :default-initargs при объявлении DEFCLASS. Эта опция используется для указания выражений, которые будут вычислены для нахождения аргументов для отдельных параметров инициализации, которые не получили значение при вызове MAKE-INSTANCE. В текущий момент времени вам не нужно беспокоиться о :default-initargs.
6)Добавление метода :after к INITIALIZE-INSTANCE является аналогом на Common Lisp определению конструктора в Java или C++, или методу init в Python.
7)Одна из ошибок, которую вы могли сделать до того, как освоились со вспомогательными методами, является в определении метода для INITIALIZE-INSTANCE, но без квалификатора :after. Если вы сделаете это, вы получите новый основной метод, который скроет метод, вызываемый по умолчанию. Вы можете удалить ненужный основной метод с помощью функций REMOVE-METHOD и FIND-METHOD. Некоторые среды разработки могут предоставлять графический интерфейс для выполнения данной задачи.
(remove-method #'initialize-instance
  (find-method #'initialize-instance () (list (find-class 'bank-account))))

8)Конечно, предоставление функции доступа в действительности не ограничивает ничего, поскольку сторонний код все равно может использовать SLOT-VALUE для прямого доступа к слотам. Common Lisp не предоставляет строгой инкапсуляции слотов, как это делают C++ и Java; однако, если автор класса предоставляет функции доступа и вы игнорируете их, вместо этого используя SLOT-VALUE, то вы должны лучше знать, что вы делаете. Кроме этого, имеется возможность использования пакетной системы, которую я буду обсуждать в главе 21, чтобы ограничить прямой доступ к некоторым слотам путем отсутствия экспорта имен слотов.
9)Одним из следствий определения SETF-функции (например, (setf foo)) является то, что если вы также определяете соответствующую функцию доступа, в нашем случае это foo, то вы можете использовать все макросы изменяющие значения, которые построены на основе SETF, такие как INCF, DECF, PUSH и POP, для нового вида места.
10)Имена "переменных", предоставляемые WITH-SLOTS и WITH-ACCESSORS не являются настоящими переменными; они реализуются специальным видом макросов, называемых символьными макросами, которые позволяют простому имени преобразовываться в произвольный код. Символьные макросы были введены в язык для поддержки WITH-SLOTS и WITH-ACCESSORS, но вы также можете использовать их для своих целей. Я их более подробно опишу в главе 20.
11)Meta Object Protocol (MOP), который не является частью стандарта языка, но поддерживается большинством реализаций Common Lisp, предоставляет функцию class-prototype, которая возвращает экземпляр класса, который может использоваться для доступа к слотам, выделенным для класса. Если вы используете реализацию, которая поддерживает MOP и вы переносите программу с другого языка, который часто использует статические переменные, то эта функция облегчит этот процесс. Но все не настолько однозначно.
12)Другими словами, Common Lisp не страдает от проблемы наследования (diamond inheritance problem), которая имеется в C++. В C++, когда один класс наследуется от двух классов, которые оба наследуют переменную от общего суперкласса, то он наследует эту переменную дважды, что ведет к беспорядку.
Предыдущая Оглавление Следующая
@2009-2013 lisper.ru