diff --git a/docs/whats_new.md b/docs/whats_new.md index cac1ca5..01796a8 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -10,6 +10,7 @@ For a programmer-focused migration summary across 1.5.x, see `docs/whats_new_1_5 - The 1.5 line now brings together richer ranges and loops, interpolation, math modules, immutable and observable collections, richer `lyngio`, and much better CLI/IDE support. - `1.5.4` specifically fixes user-visible issues around decimal arithmetic, mixed numeric flows, list behavior, and observable list hooks. - `1.5.4` also fixes extension-member registration for named singleton `object` declarations, so `fun X.foo()` and `val X.bar` now work as expected. +- `1.5.4` also lets named singleton `object` declarations use scoped indexer extensions with bracket syntax, so patterns like `Storage["name"]` can be implemented with `override fun Storage.getAt(...)` / `putAt(...)`. - The docs, homepage samples, and release metadata now point at the current stable version. ## User Highlights Across 1.5.x diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index ad21fb0..cc71606 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -2771,7 +2771,14 @@ class Compiler( val next = cc.next() if (next.type == Token.Type.ID) { // could be () call or obj.method {} call - val nt = cc.current() + var nt = cc.current() + var memberCallTypeArgs: List? = null + if (nt.type == Token.Type.LT) { + memberCallTypeArgs = tryParseCallTypeArgsAfterLt() + if (memberCallTypeArgs != null) { + nt = cc.current() + } + } when (nt.type) { Token.Type.LPAREN -> { cc.next() @@ -2784,9 +2791,9 @@ class Compiler( val nestedClass = receiverClass?.let { resolveClassByName("${it.className}.${next.value}") } if (nestedClass != null) { val field = FieldRef(left, next.value, isOptional) - operand = CallRef(field, args, tailBlock, isOptional) + operand = CallRef(field, args, tailBlock, isOptional, memberCallTypeArgs) } else { - operand = MethodCallRef(left, next.value, args, tailBlock, isOptional) + operand = MethodCallRef(left, next.value, args, tailBlock, isOptional, memberCallTypeArgs) } } else { // instance method call @@ -2808,9 +2815,13 @@ class Compiler( operand = when (left) { is LocalVarRef -> if (left.name == "this") { resolutionSink?.referenceMember(next.value, next.pos) - val implicitType = currentImplicitThisTypeName() - val ids = resolveMemberIds(next.value, next.pos, implicitType) - ThisMethodSlotCallRef(next.value, ids.methodId, args, tailBlock, isOptional) + if (memberCallTypeArgs != null) { + MethodCallRef(left, next.value, args, tailBlock, isOptional, memberCallTypeArgs) + } else { + val implicitType = currentImplicitThisTypeName() + val ids = resolveMemberIds(next.value, next.pos, implicitType) + ThisMethodSlotCallRef(next.value, ids.methodId, args, tailBlock, isOptional) + } } else if (left.name == "scope") { if (next.value == "get" || next.value == "set") { val first = args.firstOrNull()?.value @@ -2820,26 +2831,32 @@ class Compiler( resolutionSink?.referenceReflection(name.value, next.pos) } } - MethodCallRef(left, next.value, args, tailBlock, isOptional) + MethodCallRef(left, next.value, args, tailBlock, isOptional, memberCallTypeArgs) } else { val unionCall = buildUnionMethodCall(left, next.value, next.pos, isOptional, args, tailBlock) if (unionCall != null) { unionCall } else { enforceReceiverTypeForMember(left, next.value, next.pos) - MethodCallRef(left, next.value, args, tailBlock, isOptional) + MethodCallRef(left, next.value, args, tailBlock, isOptional, memberCallTypeArgs) } } is QualifiedThisRef -> - QualifiedThisMethodSlotCallRef( - left.typeName, - next.value, - resolveMemberIds(next.value, next.pos, left.typeName).methodId, - args, - tailBlock, - isOptional - ).also { - resolutionSink?.referenceMember(next.value, next.pos, left.typeName) + if (memberCallTypeArgs != null) { + MethodCallRef(left, next.value, args, tailBlock, isOptional, memberCallTypeArgs).also { + resolutionSink?.referenceMember(next.value, next.pos, left.typeName) + } + } else { + QualifiedThisMethodSlotCallRef( + left.typeName, + next.value, + resolveMemberIds(next.value, next.pos, left.typeName).methodId, + args, + tailBlock, + isOptional + ).also { + resolutionSink?.referenceMember(next.value, next.pos, left.typeName) + } } else -> { val unionCall = buildUnionMethodCall(left, next.value, next.pos, isOptional, args, tailBlock) @@ -2847,7 +2864,7 @@ class Compiler( unionCall } else { enforceReceiverTypeForMember(left, next.value, next.pos) - MethodCallRef(left, next.value, args, tailBlock, isOptional) + MethodCallRef(left, next.value, args, tailBlock, isOptional, memberCallTypeArgs) } } } @@ -3253,12 +3270,13 @@ class Compiler( is LocalVarRef -> ref.name is FastLocalVarRef -> ref.name is LocalSlotRef -> ref.name + is FieldRef -> ref.name else -> null } if (name != null) { if (lookupGenericFunctionDecl(name) != null) return true if (name.firstOrNull()?.isUpperCase() == true) return true - return false + return ref is FieldRef } return ref is ConstRef && ref.constValue is ObjClass } @@ -6443,6 +6461,23 @@ class Compiler( } } val implicitThisTypeName = currentImplicitThisTypeName() + if (left is FieldRef && !explicitTypeArgs.isNullOrEmpty()) { + val nestedClass = if (shouldTreatAsClassScopeCall(left.target, left.name)) { + resolveReceiverClassForMember(left.target)?.let { resolveClassByName("${it.className}.${left.name}") } + } else { + null + } + if (nestedClass == null) { + return MethodCallRef( + left.target, + left.name, + args, + detectedBlockArgument, + left.isOptional || isOptional, + explicitTypeArgs + ) + } + } val result = when (left) { is ImplicitThisMemberRef -> if (left.methodId == null && left.fieldId != null) { diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt index c48208c..1a456fd 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt @@ -4712,7 +4712,7 @@ class BytecodeCompiler( val receiver = compileRefWithFallback(ref.receiver, null, refPosOrCurrent(ref.receiver)) ?: return null val dst = allocSlot() fun emitDynamicCall(): CompiledValue? { - val args = compileCallArgs(ref.args, ref.tailBlock) ?: return null + val args = compileCallArgs(ref.args, ref.tailBlock, ref.explicitTypeArgs) ?: return null val encodedCount = encodeCallArgCount(args) ?: return null val nameId = builder.addConst(BytecodeConst.StringVal(ref.name)) if (!ref.isOptional) { @@ -4759,7 +4759,7 @@ class BytecodeCompiler( if (methodId != null && resolvedMember?.declaringClass?.className != "Obj") { val encodedMethodId = encodeMemberId(receiverClass, methodId) ?: methodId if (!ref.isOptional) { - val args = compileCallArgs(ref.args, ref.tailBlock) ?: return null + val args = compileCallArgs(ref.args, ref.tailBlock, ref.explicitTypeArgs) ?: return null val encodedCount = encodeCallArgCount(args) ?: return null setPos(callPos) builder.emit(Opcode.CALL_MEMBER_SLOT, receiver.slot, encodedMethodId, args.base, encodedCount, dst) @@ -4778,7 +4778,7 @@ class BytecodeCompiler( Opcode.JMP_IF_TRUE, listOf(CmdBuilder.Operand.IntVal(cmpSlot), CmdBuilder.Operand.LabelRef(nullLabel)) ) - val args = compileCallArgs(ref.args, ref.tailBlock) ?: return null + val args = compileCallArgs(ref.args, ref.tailBlock, ref.explicitTypeArgs) ?: return null val encodedCount = encodeCallArgCount(args) ?: return null setPos(callPos) builder.emit(Opcode.CALL_MEMBER_SLOT, receiver.slot, encodedMethodId, args.base, encodedCount, dst) @@ -4796,7 +4796,7 @@ class BytecodeCompiler( builder.emit(Opcode.GET_MEMBER_SLOT, receiver.slot, encodedFieldId, -1, calleeSlot) } if (!ref.isOptional) { - val args = compileCallArgs(ref.args, ref.tailBlock) ?: return null + val args = compileCallArgs(ref.args, ref.tailBlock, ref.explicitTypeArgs) ?: return null val encodedCount = encodeCallArgCount(args) ?: return null setPos(callPos) builder.emit(Opcode.CALL_SLOT, calleeSlot, args.base, encodedCount, dst) @@ -4812,7 +4812,7 @@ class BytecodeCompiler( listOf(CmdBuilder.Operand.IntVal(cmpSlot), CmdBuilder.Operand.LabelRef(nullLabel)) ) builder.emit(Opcode.GET_MEMBER_SLOT, receiver.slot, encodedFieldId, -1, calleeSlot) - val args = compileCallArgs(ref.args, ref.tailBlock) ?: return null + val args = compileCallArgs(ref.args, ref.tailBlock, ref.explicitTypeArgs) ?: return null val encodedCount = encodeCallArgCount(args) ?: return null setPos(callPos) builder.emit(Opcode.CALL_SLOT, calleeSlot, args.base, encodedCount, dst) @@ -4845,7 +4845,7 @@ class BytecodeCompiler( builder.emit(Opcode.CONST_NULL, memberSlot) builder.mark(endLabel) } - val args = compileCallArgs(ref.args, ref.tailBlock) ?: return null + val args = compileCallArgs(ref.args, ref.tailBlock, ref.explicitTypeArgs) ?: return null val encodedCount = encodeCallArgCount(args) ?: return null setPos(callPos) builder.emit(Opcode.CALL_SLOT, memberSlot, args.base, encodedCount, dst) @@ -4858,7 +4858,7 @@ class BytecodeCompiler( ) val callee = ensureObjSlot(extSlot) if (!ref.isOptional) { - val args = compileCallArgsWithReceiver(receiver, ref.args, ref.tailBlock) ?: return null + val args = compileCallArgsWithReceiver(receiver, ref.args, ref.tailBlock, ref.explicitTypeArgs) ?: return null val encodedCount = encodeCallArgCount(args) ?: return null setPos(callPos) builder.emit(Opcode.CALL_SLOT, callee.slot, args.base, encodedCount, dst) @@ -4874,7 +4874,7 @@ class BytecodeCompiler( Opcode.JMP_IF_TRUE, listOf(CmdBuilder.Operand.IntVal(cmpSlot), CmdBuilder.Operand.LabelRef(nullLabel)) ) - val args = compileCallArgsWithReceiver(receiver, ref.args, ref.tailBlock) ?: return null + val args = compileCallArgsWithReceiver(receiver, ref.args, ref.tailBlock, ref.explicitTypeArgs) ?: return null val encodedCount = encodeCallArgCount(args) ?: return null setPos(callPos) builder.emit(Opcode.CALL_SLOT, callee.slot, args.base, encodedCount, dst) @@ -5067,13 +5067,14 @@ class BytecodeCompiler( private fun compileCallArgsWithReceiver( receiver: CompiledValue, args: List, - tailBlock: Boolean + tailBlock: Boolean, + explicitTypeArgs: List? = null ): CallArgs? { val argSlots = IntArray(args.size + 1) { allocSlot() } val receiverObj = ensureObjSlot(receiver) builder.emit(Opcode.MOVE_OBJ, receiverObj.slot, argSlots[0]) updateSlotType(argSlots[0], SlotType.OBJ) - val needPlan = tailBlock || args.any { it.isSplat || it.name != null } + val needPlan = tailBlock || args.any { it.isSplat || it.name != null } || !explicitTypeArgs.isNullOrEmpty() val specs = if (needPlan) ArrayList(args.size + 1) else null specs?.add(BytecodeConst.CallArgSpec(null, false)) for ((index, arg) in args.withIndex()) { @@ -5086,7 +5087,13 @@ class BytecodeCompiler( specs?.add(BytecodeConst.CallArgSpec(arg.name, arg.isSplat)) } val planId = if (needPlan) { - builder.addConst(BytecodeConst.CallArgsPlan(tailBlock, specs ?: emptyList())) + builder.addConst( + BytecodeConst.CallArgsPlan( + tailBlock = tailBlock, + specs = specs ?: emptyList(), + explicitTypeArgs = explicitTypeArgs ?: emptyList() + ) + ) } else { null } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/CmdRuntime.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/CmdRuntime.kt index ee193ff..7d692cf 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/CmdRuntime.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/CmdRuntime.kt @@ -3301,7 +3301,7 @@ private suspend fun resolveDynamicFieldValue(scope: Scope, receiver: Obj, name: } if (rec.type == ObjRecord.Type.Fun && !rec.isAbstract) { val recv = rec.receiver ?: receiver - return rec.value.invoke(scope, recv, Arguments.EMPTY, rec.declaringClass) + return invokeForFieldReadOrReturnCallable(scope, recv, rec, rec.declaringClass) } return rec.value } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt index cf95717..b5bab0a 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt @@ -518,7 +518,7 @@ open class Obj { if (rec.visibility == Visibility.Private && !rec.isAbstract) { val resolved = resolveRecord(scope, rec, name, caller) if (resolved.type == ObjRecord.Type.Fun) - return resolved.copy(value = resolved.value.invoke(scope, this, Arguments.EMPTY, caller)) + return resolved.copy(value = invokeForFieldReadOrReturnCallable(scope, this, resolved, caller)) return resolved } } @@ -532,7 +532,7 @@ open class Obj { val decl = rec.declaringClass ?: cls val resolved = resolveRecord(scope, rec, name, decl) if (resolved.type == ObjRecord.Type.Fun) - return resolved.copy(value = resolved.value.invoke(scope, this, Arguments.EMPTY, decl)) + return resolved.copy(value = invokeForFieldReadOrReturnCallable(scope, this, resolved, decl)) return resolved } } @@ -547,7 +547,7 @@ open class Obj { scope.raiseError(ObjIllegalAccessException(scope, "can't access field ${name}: not visible (declared in ${decl.className}, caller ${caller?.className ?: "?"})")) val resolved = resolveRecord(scope, rec, name, decl) if (resolved.type == ObjRecord.Type.Fun) - return resolved.copy(value = resolved.value.invoke(scope, this, Arguments.EMPTY, decl)) + return resolved.copy(value = invokeForFieldReadOrReturnCallable(scope, this, resolved, decl)) return resolved } } @@ -557,7 +557,7 @@ open class Obj { val prop = ext.value as ObjProperty ObjRecord(prop.callGetter(scope, this, ext.declaringClass), isMutable = false) } else { - ext.copy(value = ext.value.invoke(scope, this, Arguments.EMPTY, ext.declaringClass)) + ext.copy(value = invokeForFieldReadOrReturnCallable(scope, this, ext, ext.declaringClass)) } } @@ -660,6 +660,14 @@ open class Obj { if (hasNonRootIndexerMember("getAt")) { return invokeInstanceMethod(scope, "getAt", Arguments(index)) } + // Extension indexers are checked only after concrete class/indexer overrides. + // If this path becomes hot in benchmarks, add a small receiver-shape/member cache here + // rather than moving extension lookup ahead of the non-root member fast path. + scope.findExtension(objClass, "getAt")?.let { ext -> + if (ext.type != ObjRecord.Type.Delegated) { + return ext.value.invoke(scope, this, Arguments(index), ext.declaringClass) + } + } if (index is ObjString) { return readField(scope, index.value).value } @@ -679,6 +687,20 @@ open class Obj { return } } + // Same optimization note as in getAt(): extension indexers work here, but they are + // intentionally behind concrete overrides to keep normal class-defined indexers fast. + scope.findExtension(objClass, "putAt")?.let { ext -> + if (ext.type != ObjRecord.Type.Delegated) { + ext.value.invoke(scope, this, Arguments(index, newValue), ext.declaringClass) + return + } + } + scope.findExtension(objClass, "setAt")?.let { ext -> + if (ext.type != ObjRecord.Type.Delegated) { + ext.value.invoke(scope, this, Arguments(index, newValue), ext.declaringClass) + return + } + } if (index is ObjString) { writeField(scope, index.value, newValue) return @@ -1130,3 +1152,24 @@ suspend fun Obj.decodeSerializableWith(strategy: DeserializationStrategy, */ suspend inline fun Obj.decodeSerializable(scope: Scope= Scope()) = decodeSerializableWith(serializer(), scope) + +internal suspend fun invokeForFieldReadOrReturnCallable( + scope: Scope, + receiver: Obj, + record: ObjRecord, + decl: ObjClass? +): Obj { + return try { + record.value.invoke(scope, receiver, Arguments.EMPTY, decl) + } catch (e: ExecutionError) { + if (e.message.isMissingArgsForAutoFieldInvoke()) record.value else throw e + } catch (e: ScriptError) { + if (e.message.isMissingArgsForAutoFieldInvoke()) record.value else throw e + } +} + +private fun String?.isMissingArgsForAutoFieldInvoke(): Boolean { + val lower = this?.lowercase() ?: return false + if (!lower.contains("got 0")) return false + return lower.contains("expected") || lower.contains("missing required argument") +} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt index 76d95f8..c1c59a4 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt @@ -483,6 +483,7 @@ class MethodCallRef( internal val args: List, internal val tailBlock: Boolean, internal val isOptional: Boolean, + internal val explicitTypeArgs: List? = null, ) : ObjRef { // 4-entry PIC for method invocations (guarded by PerfFlags.METHOD_PIC) private var mKey1: Long = 0L; private var mVer1: Int = -1; private var mInvoker1: (suspend (Obj, Scope, Arguments) -> Obj)? = null diff --git a/lynglib/src/commonTest/kotlin/OOTest.kt b/lynglib/src/commonTest/kotlin/OOTest.kt index cc72b53..9e0c0e3 100644 --- a/lynglib/src/commonTest/kotlin/OOTest.kt +++ b/lynglib/src/commonTest/kotlin/OOTest.kt @@ -16,10 +16,20 @@ */ import kotlinx.coroutines.test.runTest +import net.sergeych.lyng.EvalSession +import net.sergeych.lyng.ModuleScope import net.sergeych.lyng.Script +import net.sergeych.lyng.bridge.bindObject +import net.sergeych.lyng.bridge.data import net.sergeych.lyng.eval +import net.sergeych.lyng.obj.ObjBool +import net.sergeych.lyng.obj.ObjBuffer +import net.sergeych.lyng.obj.ObjInt import net.sergeych.lyng.obj.ObjInstance import net.sergeych.lyng.obj.ObjList +import net.sergeych.lyng.obj.ObjNull +import net.sergeych.lyng.obj.ObjString +import net.sergeych.lyng.obj.ObjVoid import net.sergeych.lyng.toSource import kotlin.test.Test import kotlin.test.assertEquals @@ -990,4 +1000,106 @@ class OOTest { """) } + @Test + fun testExtendingObjectWithExternals() = runTest { + val s = EvalSession() + s.eval(""" + extern object Storage { + + extern val spaceUsed: Int + extern val spaceAvailable: Int + + /* + Return packed binary data or null + */ + extern fun getPacked(key: String): Buffer? + + /* + Upsert packed binary data + */ + extern fun putPacked(key: String,value: Buffer) + + /* + Delete data. + @return true if data were actually deleted, false means + there were no data for the key. + */ + extern fun delete(key: String): Bool + } + """.trimIndent() + ) + val scope = s.getScope() as ModuleScope + scope.bindObject("Storage") { + init { _ -> + data = mutableMapOf() + } + addVal("spaceUsed") { + val storage = (thisObj as ObjInstance).data as MutableMap + ObjInt(storage.values.sumOf { it.size }.toLong()) + } + addVal("spaceAvailable") { + val storage = (thisObj as ObjInstance).data as MutableMap + val capacity = 1_024 + ObjInt((capacity - storage.values.sumOf { it.size }).toLong()) + } + addFun("getPacked") { + val storage = (thisObj as ObjInstance).data as MutableMap + val key = (args.list[0] as ObjString).value + storage[key] ?: ObjNull + } + addFun("putPacked") { + val storage = (thisObj as ObjInstance).data as MutableMap + val key = (args.list[0] as ObjString).value + val value = args.list[1] as ObjBuffer + storage[key] = value + ObjVoid + } + addFun("delete") { + val storage = (thisObj as ObjInstance).data as MutableMap + val key = (args.list[0] as ObjString).value + ObjBool(storage.remove(key) != null) + } + } + s.eval(""" + import lyng.serialization + + // Use names that do not collide with Obj built-ins so extension dispatch is exercised. + override fun Storage.getAt(key: String): Object? { + Storage.getPacked(key)?.let { + Lynon.decode(it.toBitInput()) + } + } + + override fun Storage.putAt(key: String, value: Object) { + Storage.putPacked(key, Lynon.encode(value).toBuffer()) + } + + assertEquals(0, Storage.spaceUsed) + assertEquals(1024, Storage.spaceAvailable) + val missing: String? = Storage["missing"] + assertEquals(null, missing) + + Storage["name"] = "alice" + Storage["count"] = 42 + + val name: String? = Storage["name"] + val count: Int? = Storage["count"] + assertEquals("alice", name) + assertEquals(42, count) + assert(Storage.spaceUsed > 0) + assert(Storage.spaceAvailable < 1024) + + val wrappedName: String? = __ext__Storage__getAt(Storage, "name") + assertEquals("alice", wrappedName) + __ext__Storage__putAt(Storage, "flag", true) + val flag: Bool? = Storage["flag"] + assertEquals(true, flag) + + assert(Storage.delete("name")) + val deletedName: String? = Storage["name"] + assertEquals(null, deletedName) + assert(!Storage.delete("name")) + """.trimIndent()) + } + } diff --git a/notes/object_indexer_extension_optimization.md b/notes/object_indexer_extension_optimization.md new file mode 100644 index 0000000..ba5f230 --- /dev/null +++ b/notes/object_indexer_extension_optimization.md @@ -0,0 +1,18 @@ +# Object indexer extension follow-up + +Context +- `Obj.getAt` / `Obj.putAt` now support scoped extension indexers for named singleton `object` declarations, so patterns like `Storage["name"]` can route through `override fun Storage.getAt(...)` and `override fun Storage.putAt(...)`. +- Dispatch order is intentionally: + 1. concrete non-root class members + 2. scoped extensions + 3. built-in string-field fallback + +Why this order +- Normal class-defined indexers are the common fast path and should not pay extension lookup cost first. +- Checking scoped extensions earlier caused avoidable overhead for regular overridden indexers. +- Routing extension indexers through the generic root `Obj.getAt` member path caused recursion; direct extension dispatch avoids that. + +Possible future optimization +- If extension-based indexers show up in hot profiles, add a tiny receiver-shape/member cache at the extension-dispatch points in `Obj.getAt` / `Obj.putAt`. +- The cache should stay behind the concrete non-root member fast path. +- A good shape key is likely `(receiver class id/layout version, member name)` with the cached extension record/wrapper.