Pharo - высокодинамичный язык с развитым рефлексивным API. В частности, переменные экземпляра класса реифицируются как объекты Slot. Эта функция позволяет манипулировать переменными экземпляра (или слотами) класса как любым объектом в системе.

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

Слоты

Комментарий к классу Slot начинается следующим образом:

Я мета-объект для доступа к слоту в объекте.

Я определяю протокол для чтения (#read :) и записи (#write: to :) значений.

Что интересно в слотах, так это то, что они предоставляют хуки, позволяющие выполнять настраиваемые действия, когда хранимое в нем значение либо прочитано (#read:), либо записано (#write: to: ). Они будут полезны для нашей реализации TypedSlot.

Но сначала давайте взглянем на слоты Цвет, чтобы увидеть, как он обычно работает.

Color slots. "{#rgb => InstanceVariableSlot. #cachedDepth => InstanceVariableSlot. #cachedBitPattern => InstanceVariableSlot. #alpha => InstanceVariableSlot}"

Color использует слоты по умолчанию системы под названием InstanceVariableSlot. Он имеет 4 слота с именами #rgb, #cacheDepth, #cachedBitPattern и #alpha.

Можно задать цвет для определенного слота с помощью метода #slotNamed:.

slot := Color slotNamed: #rgb.

Слот знает свое название:

slot name. "#rgb"

И мы можем использовать его для чтения значений #rgb различных экземпляров Color.

slot read: Color black. "0"
slot read: Color white. "1073741823"
slot read: Color blue. "1023"
slot read: Color green. "1047552"

Или написать их (не делайте этого на практике, это нарушает инкапсуляцию, а поскольку белый цвет кэшируется, он будет вносить большой беспорядок в цвета системы, превращая все белое в черное. ;-)):

colorWeModifyWithSlot := Color white.
slot read: colorWeModifyWithSlot. “1073741823”
slot write: 0 to: colorWeModifyWithSlot.
slot read: colorWeModifyWithSlot. “0”

Теперь, когда мы лучше понимаем, что такое слоты, давайте посмотрим, как они работают. Для этого рассмотрим конкретный пример: реализацию TypedSlot. Этот новый вид слота позволит проверить тип объекта, который одна попытка сохранить в слоте, является ожидаемым.

Набранные слоты

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

Object subclass: #MockObjectForTypedSlotUsingClass
 slots: { #testSlot => TypedSlot type: Integer }
 classVariables: {  }
 package: 'TypedSlot-Class-Tests'

Вышеупомянутый класс содержит TypedSlot с именем #testSlot, который позволяет хранить только объекты Integer.

Теперь мы можем писать наши модульные тесты. Сначала мы создаем подкласс TestCase:

TestCase subclass: #TypedSlotClassDescriptionTest
 slots: {  }
 classVariables: {  }
 package: 'TypedSlot-Class-Tests'

Затем мы реализуем #testWriteTo, который описывает поведение, которое мы ожидаем от метода TypedSlot ›› #write: to:.

TypedSlotClassDescriptionTest>>testWriteTo
 | testSlot mockObject |
 testSlot := MockObjectForTypedSlotUsingClass slotNamed: #testSlot.
 mockObject := MockObjectForTypedSlotUsingClass new.
 
 self shouldnt: [ testSlot write: 1 to: mockObject ] raise: TypeViolation.
 self assert: (testSlot read: mockObject) equals: 1.
 
 self shouldnt: [ testSlot write: nil to: mockObject ] raise: TypeViolation.
 self assert: (testSlot read: mockObject) equals: nil.
 
 self should: [ testSlot write: 'string' to: mockObject ] raise: TypeViolation.
 self assert: (testSlot read: mockObject) equals: nil.

Вернемся к созданию фиктивного класса, синтаксис для создания TypedSlot с типом Integer следующий:

#testSlot => TypedSlot type: Integer

Фактически, мы хотели бы, чтобы наша реализация была расширяемой. Таким образом, мы сделаем это так, чтобы любой объект мог быть предоставлен в качестве аргумента #type:, если он мог проверять, может ли конкретный экземпляр объекта храниться в TypedSlot.

Эта проверка будет выполняться с помощью #checkObjectType:, который будет реализован с помощью объекта типа. В случае классов мы ожидаем, что поведение этого метода будет отвечать требованиям, описанным в следующем тесте:

TypedSlotClassDescriptionTest>>testCheckObjectType
 self shouldnt: [ Integer checkObjectType: 1 ] raise: TypeViolation.
 self shouldnt: [ Integer checkObjectType: -1 ] raise: TypeViolation.
 self shouldnt: [ Fraction checkObjectType: 1/2 ] raise: TypeViolation.
 self shouldnt: [ Fraction checkObjectType: 0.5s02 ] raise: TypeViolation.
 
 self
  should: [ Integer checkObjectType: 'string' ]
  raise: TypeViolation
  withExceptionDo: [ :typeViolation | 
   self assert: typeViolation expectedType equals: Integer.
   self assert: typeViolation objectAttemptedToBeWritten equals: 'string' ].
  
 self
  should: [ ScaledDecimal checkObjectType: 1/2 ]
  raise: TypeViolation
  withExceptionDo: [ :typeViolation | 
   self assert: typeViolation expectedType equals: ScaledDecimal.
   self assert: typeViolation objectAttemptedToBeWritten equals: 1/2 ].

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

Конкретная ошибка в модели нарушения типа

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

Error subclass: #TypeViolation
 slots: { #expectedType. #objectAttemptedToBeWritten }
 classVariables: {  }
 package: 'TypedSlot-Errors'

Мы создаем средства доступа и мутаторы для переменных экземпляра #expectedType #objectAttemptedToBeWritten и создаем метод на стороне класса, чтобы упростить создание экземпляра TypeViolation:

TypeViolation>>expectedType: aType objectAttemptedToBeWritten: object
 ^ self new
  expectedType: aType;
  objectAttemptedToBeWritten: object;
  yourself

Делегирование проверки типов в ClassDescription

Во-вторых, мы реализуем #checkObjectType: на стороне экземпляра ClassDescription, чтобы гарантировать, что и классы, и метаклассы могут проверять тип объекта и, таким образом, использоваться для типа -проверка.

ClassDescription>>checkObjectType: anObject
 (anObject isKindOf: self)
  ifFalse: [ (TypeViolation expectedType: self objectAttemptedToBeWritten: anObject) signal ]

Как видно из приведенного выше кода, реализация довольно проста. Если anObject, предоставленный в качестве аргумента, не является разновидностью ClassDescription, возникает TypeViolation. Иначе ничего не происходит, объект можно хранить в слоте. На этом этапе #testCheckObjectType должен пройти.

Реализация TypedSlot

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

IndexedSlot subclass: #TypedSlot
 slots: { #type }
 classVariables: {  }
 package: 'TypedSlot-Core'

Наш TypedSlot содержит переменную экземпляра #type, для которой мы создаем средство доступа и мутатор. Кроме того, мы инициализируем тип по умолчанию как Object:

TypedSlot>>initialize
 super initialize.
 self type: Object

Поскольку TypedSlot содержит состояние, мы переопределяем #definitionString, # = и #hash в соответствии с документацией Слот.

Чтобы реализовать #definitionString, мы создадим метод #definitionStringOn:, который печатает определение слота в потоке, предоставленном в качестве параметра, и мы будем вызывать этот метод из #definitionString.

TypedSlot>>definitionStringOn: aStream
 aStream 
  store: self name;
  nextPutAll: ' => ';
  nextPutAll: self class name;
  nextPutAll: ' type: '.
 self type printOn: aStream
TypedSlot>>definitionString
 ^ String streamContents: [ :stream |
  self definitionStringOn: stream ]

Кроме того, мы переопределяем #hasSameDefinitionAs:, чтобы учесть #definitionString (это не учитывается при реализации суперкласса).

TypedSlot>>hasSameDefinitionAs: otherSlot
^ (super hasSameDefinitionAs: otherSlot) 
  and: [ self definitionString = otherSlot definitionString ]

Методы # = и #hash simple учитывают дополнительное состояние слота:

TypedSlot>>= anObject
 ^ super = anObject and: [ self type = anObject type ]
TypedSlot>>hash
 ^ super hash bitXor: self type hash

Следующий реализуемый нами метод - #checkTypeOfValue:. Его цель - делегировать проверку типа объекту, хранящемуся в переменной экземпляра #type. Кроме того, этот метод гарантирует, что если сохраняемое значение равно нулю, проверка типа не выполняется. Такое поведение гарантирует, что всегда можно установить значение слота равным нулю.

TypedSlot>>checkTypeOfValue: newValue
 newValue ifNil: [ ^ self ].
 
 type checkObjectType: newValue

Наконец, мы можем переопределить ловушку #write: to: для проверки типов перед фактическим сохранением нового значения.

TypedSlot>>write: newValue to: anObject
 self checkTypeOfValue: newValue.
 
 ^ super write: newValue to: anObject

Вот и все, TypedSlot теперь полностью реализован и все тесты пройдены.

Оптимизация чтения

Теперь реализован TypedSlot, который использует отражательную способность как для записи, так и для чтения (что означает, что оба элемента #write: to: и #read: будут использоваться, позволяя нам определять индивидуальное поведение). При записи слота проверяется тип записываемого объекта. С другой стороны, при чтении слота ничего делать не нужно. Таким образом, нет необходимости (и не нужно) отражать значение, удерживаемое слотом. Выполнение такого чтения имеет накладные расходы, ведущие к более медленному доступу к значению, чем при использовании слотов по умолчанию.

Чтобы оптимизировать операцию чтения, выполняемую TypedSlot, мы можем использовать тот же прием, что и InstanceVariableSlot ›› #emitValue:. Этот метод отвечает за генерацию байт-кода, расположенного в тех местах, где слот читается в коде. В IndexedSlot байт-код таков, что можно переопределить обработчик #read: для выполнения настраиваемых действий. Поскольку нам не нужно этого делать, мы можем переопределить #emitValue: для генерации байт-кода, который будет читать только значение слота, без выполнения ловушки #read:. Такой байт-код можно сгенерировать следующим образом:

TypedSlot>>emitValue: methodBuilder
    methodBuilder pushInstVar: index.

Заключение

Этот пост демонстрирует, как легко расширить язык Pharo. В частности, мы реализовали TypedSlot, который гарантирует, что объект неправильного типа не может быть записан. Для тех, кто хочет пойти дальше, более продвинутую реализацию можно найти здесь. В этой реализации реализовано больше функций, таких как использование Trait s или BlockClosure s в качестве типа. Я даже провел небольшой эксперимент по реализации интерфейсов (по аналогии с концепцией интерфейса в Java) для Pharo.

Что интересно с небольшим экспериментом в этом блоге, так это то, что он открывает другую перспективу: необходимость в композиции слотов. Действительно, хотелось бы иметь возможность добавлять проверку типов в любой слот. Это позволит, например, обеспечить проверку типа для слотов, которые выполняют дальнейшие действия, если тип принят. Уже ведутся эксперименты с составом слотов, поэтому в какой-то момент эта функция появится в Pharo.

Благодарности

Спасибо Маркусу Денкеру за просмотр этого сообщения в блоге.