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.
Благодарности
Спасибо Маркусу Денкеру за просмотр этого сообщения в блоге.