Skip to content

Properties as protocol members, and an IBufferWriter write overload - #18

Merged
holonsoftFounder merged 1 commit into
mainfrom
feat/v4-property-support
Sep 14, 2026
Merged

holonsoftFounder merged 1 commit into
mainfrom
feat/v4-property-support

Conversation

@holonsoftFounder

Copy link
Copy Markdown
Member

Property support

A protocol member may now be a property as well as a field. The same attributes work on both.

The test that carries the weight is the first one: the same protocol declared once with fields and once with properties, covering every supported type (sbyte through decimal, Guid, DateTime, enum, string), has to produce byte for byte the same frame. It does.

Properties cost nothing

The accessors were already compiled expressions; MakeMemberAccess emits a call to the getter or setter, and the JIT inlines a trivial auto property accessor into the same code a field access produces.

Method fields properties
Read 38 byte POCO 58.91 ns / 56 B 60.76 ns / 56 B
Read into reused instance 31.24 ns / 0 B 28.40 ns / 0 B
Write 38 byte POCO 57.02 ns / 64 B 56.83 ns / 64 B

The read row is 3% apart, the other two are inside the noise in the other direction. Allocation is identical everywhere.

The old restriction came from FieldInfo being wired through ConverterFieldInfo, FastInvoke and the range violation event, not from anything about the conversion itself.

What is accepted and what is not

  • An init only or private setter is fine, so a POCO can stay immutable from the outside and still be filled from a frame. Verified empirically rather than assumed: Expression.Assign on an init property compiles and runs.
  • A property carrying the attribute with no setter is rejected at Prepare() with a message that says why, since reading a frame would have to assign it.
  • Computed properties, indexers and static properties without the attribute are ignored, not rejected, so they can sit next to the protocol members undisturbed.
  • A bool property occupies one byte, not the four Marshal.SizeOf(typeof(bool)) reports. There is a test for exactly that, because GetByteCount and the writer would otherwise agree on the wrong number and nothing else would notice.

IBufferWriter

ConvertToByteArray(T, IBufferWriter<byte>) writes a frame into a pipeline's own buffer.

The trap it has to avoid: GetSpan(n) may return more than n bytes. The cursor gets exactly the frame and Advance is told the frame length, not the span length. TestBufferWriterApi checks this with a writer that deliberately over-returns and fills the spare room with a marker, then writes several frames and asserts they stay contiguous.

Write into a reused buffer 56.15 ns 0 B
Write into an IBufferWriter 57.17 ns 0 B

About a nanosecond for the GetSpan/Advance pair, and nothing allocated.

BREAKING

OnRangeViolationDelegate takes a MemberInfo instead of a FieldInfo, because FieldInfo cannot describe a property. One word for most handlers:

// 3.x
void OnRangeViolation(FieldInfo field, out ConverterRangeViolationBehaviour behaviour)
// 4.0
void OnRangeViolation(MemberInfo member, out ConverterRangeViolationBehaviour behaviour)

A handler that read field.FieldType has to ask the member for it, which is the part that will not be obvious from the compiler error, so the README upgrade section spells it out.

ConverterFieldInfo<T>.FieldInfo is now Member (a MemberInfo), with MemberType alongside. It was removed rather than left returning null for properties, so the failure is a compile error and not a NullReferenceException at runtime.

README

Rewritten to lead with the numbers, per your request: the speed table is now the first thing after the one line description, with the zero allocation loop as a code sample, the "byte order is free" point, and the garbage-per-second figure at 100k msg/s. The baseline-before-v4.md pointer sits next to it, since the credibility is in the method being checkable.

Safety

150/150 green, 20 of them new. Golden vectors unmoved.

🤖 Generated with Claude Code

…load

A protocol member may now be a property as well as a field. The same attributes
work on both and the produced bytes are identical, which the first test asserts
directly: the same protocol declared once with fields and once with properties,
covering every supported type, has to come out byte for byte the same.

Properties cost nothing. The accessors were already compiled expressions,
MakeMemberAccess emits a call to the getter or setter, and the JIT inlines a
trivial auto property accessor into the same code a field access produces:

    Read  38 byte POCO          fields 58.91 ns    properties 60.76 ns
    Read  into reused instance  fields 31.24 ns    properties 28.40 ns
    Write 38 byte POCO          fields 57.02 ns    properties 56.83 ns

with identical allocation in every row. The old restriction came from FieldInfo
being wired through ConverterFieldInfo, FastInvoke and the range violation
event, not from anything about the conversion itself.

An init only or private setter is fine, so a POCO can stay immutable from the
outside and still be filled from a frame. A property that carries the attribute
but has no setter is rejected at Prepare() with a message that says why, since
reading a frame would have to assign it. Computed properties, indexers and
static properties without the attribute are ignored rather than rejected.

ConvertToByteArray(T, IBufferWriter<byte>) writes a frame into a pipeline's own
buffer. GetSpan is free to hand out more than was asked for, so the cursor gets
exactly the frame and Advance is told the frame length, not the span length,
which TestBufferWriterApi checks with a writer that deliberately over returns.
It costs 57.17 ns against 56.15 ns for a buffer you own yourself, and allocates
nothing either.

BREAKING: OnRangeViolationDelegate takes a MemberInfo instead of a FieldInfo,
because FieldInfo cannot describe a property. A handler that only reads the name
needs no change beyond the parameter type. ConverterFieldInfo<T>.FieldInfo is
now Member, typed MemberInfo, with MemberType alongside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@holonsoftFounder
holonsoftFounder merged commit 2c96ae7 into main Sep 14, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant