Streams, Readers, and Writers

Understand byte transfer, capabilities, buffering, reader/writer protocols, and native lifetime before porting System.IO code.

System.IOStreamsOwnership

Start with the stream contract, not the class name

System::IO::Stream is the common protocol for a sequence of bytes. It makes polymorphic readers, writers, compression adapters, network streams, and in-memory buffers possible, but it deliberately implements a smaller contract than the managed System.IO.Stream. There are no asynchronous members on the base class, lengths and positions use the project’s 32-bit intcs, and several capability members have defaults instead of being abstract.

MemberBase behaviorSubclass obligation
Read(buffer, offset, count)Pure virtualReturn zero at end-of-stream; validate the range and report partial reads honestly
Close()Pure virtualEnd access to owned resources; repeated close should be safe where the concrete type promises it
LengthPure virtualReturn byte length or reject the operation for an unsupported/closed stream
WriteThrows NotSupportedExceptionOverride together with CanWrite
WriteByteDelegates to WriteOverride only for a more direct implementation
CanReadtrueOverride for unreadable streams and when liveness changes the answer
CanWritefalseOverride for every writable stream
CanSeekfalseOverride for every seekable stream
PositionThrows NotSupportedExceptionOverride getter and setter for seekable streams
SeekComputes a new position from Begin, Current, or EndMay inherit it when Position and Length are correct
SetLengthThrows NotSupportedExceptionOverride only when resizing is meaningful
ReadByteReads one byte through ReadReturn -1 only at clean end-of-stream
FlushNo-opOverride for buffering or an external device
The capability defaults are asymmetric

A writable subclass that overrides Write but inherits CanWrite=false is rejected by BinaryWriter. An unreadable subclass that inherits CanRead=true is accepted by reader constructors and fails later. Custom streams must state all three capabilities explicitly.

Partial transfers and raw buffers

Read means “up to count bytes,” not “exactly count bytes.” File, network, compression, and buffered implementations can return a shorter count without reaching the logical end of the complete data structure the application is reading. BinaryReader uses exact reads for fixed-width primitives, while ReadBytes(count) deliberately returns a shorter vector at end-of-stream.

The raw-pointer overloads cannot know the allocation size behind buffer. They validate null pointers, negative offsets, and negative counts where their contracts say so, but the caller must still provide storage for offset + count elements. Passing a pointer to a smaller array is ordinary C++ memory corruption, not a managed bounds exception.

Seeking before byte zero throws IOException. Directly assigning a negative position throws ArgumentOutOfRangeException. That distinction follows the implemented contract: an invalid relative seek and an invalid property value are different doors.

Concrete stream matrix

TypeStorageRead/write/seekOwnership and close behavior
MemoryStreamOwned std::vector<bytecs>Readable and seekable while open; writable when constructed writableCopies constructor bytes. ToArray and GetBuffer remain available after close
FileStreamOwned std::fstreamCapabilities follow requested access and open stateDestructor closes the file. Operations requiring the file throw after Close
BufferedStreamOne shared read/write buffer over a Stream*Delegates capabilities while open; accounts for unread or unwritten buffered bytesBorrowed by default; ownsStream=true makes close/destruction close the inner stream
UnmanagedMemoryStreamCaller-owned raw byte rangeFixed-capacity read/write/seek according to FileAccessNever frees the buffer. Close invalidates stream operations and pointer access
Compression streamsZLIB state plus another Stream*Direction depends on compression modeBelong to IO.Compression; inspect leaveOpen at the adapter boundary
NetworkStreamSocketSequential network I/O; not a seekable fileBelongs to Net.Sockets; socket ownership is selected at construction

MemoryStream

The empty constructor creates a writable stream. The pointer-and-size constructor copies its source, so subsequent changes to the caller’s buffer do not affect the stream. A null pointer is accepted only for a zero-size range, which is Sharp Runtime’s native spelling of an empty source.

Writes grow the vector as needed, seeking changes the shared read/write position, and SetLength truncates or zero-extends the buffer. ToArray returns an independent copy. GetBuffer returns a const reference to the live vector; later growth can reallocate it, so references, iterators, spans, or element pointers derived from that vector must not be retained across writes.

After Close, reads, writes, seeks, length, and position throw ObjectDisposedException. CanRead and CanSeek become false. CanWrite deliberately continues to report the construction-time writable flag, matching the asymmetry of .NET’s MemoryStream. Capability checks therefore do not replace the lifecycle contract.

FileStream

FileStream validates FileMode and FileAccess combinations before opening. Append cannot include read access; truncate, create, create-new, and append require write access. The path-only constructor opens an existing file, and the mode-only overload defaults to write for append and read/write for other modes.

The selected source revision enforces the closed state consistently. Read, Write, WriteByte, Flush, Length, Position, position assignment, and SetLength all pass through one open-state check. CanRead, CanWrite, and CanSeek become false after close. Length flushes pending writes and queries the file rather than returning a construction-time cache, so extending a stream is visible without closing and reopening it.

Access mismatches are reported rather than silently delegated to a failing std::fstream. Reading an append-only stream or writing a read-only stream throws NotSupportedException. Missing parents and missing files are distinguished where the constructor has evidence to do so, while other native open failures become IOException.

BufferedStream

BufferedStream maintains one internal buffer, used for either reads or writes at a time. It batches small operations, flushes writes before querying length or switching to reads, and reconciles unread buffered data before a seek. The default buffer size is 4,096 bytes; callers can choose another positive size.

Buffering changes performance and the moment bytes reach the inner stream, not the logical ordering. Flush sends pending writes or reconciles buffered reads. Close flushes before ending the wrapper and closes the inner stream only when the wrapper was constructed as its owner. Async I/O and .NET’s transient “shadow buffer” optimization are not implemented.

UnmanagedMemoryStream and accessor

UnmanagedMemoryStream wraps a raw, fixed-capacity range. It never allocates, moves, or frees that range. Length is the initialized data length; capacity is the maximum writable extent. A write beyond capacity throws. getPositionPointerProperty() exposes the current raw address only while the stream is open.

UnmanagedMemoryAccessor provides fixed-width primitive reads and writes at explicit byte positions. It does not implement generic blittable-structure marshalling, generic arrays, or decimal marshalling because the CLR layout machinery those operations depend on has no direct native equivalent here.

Wrapper ownership and lifetime

Readers and writers store raw Stream* values. They do not extend the stream’s lifetime. The wrapper must be destroyed or closed before a stack-owned stream leaves scope, and an asynchronously used stream must remain stable for the complete operation.

WrapperDefaultleaveOpen=trueWrapper state after Close
BinaryReaderCloses the streamLeaves the stream openDisposed; later reader operations reject use
BinaryWriterCloses the streamLeaves the stream openDisposed; later writer operations reject use
StreamReaderCloses an external stream; path constructor owns its FileStreamLeaves the stream openCompatibility gap: the wrapper has no closed-state flag
StreamWriterCloses an external stream; path constructor owns its FileStreamLeaves the stream openCompatibility gap: the wrapper has no closed-state flag
StringReaderOwns a string copyNot applicableInherits a no-op Close and remains readable
StringWriterOwns an ostringstreamNot applicableInherits a no-op Close and remains writable
Do not depend on post-close text-wrapper use

StreamReader, StreamWriter, StringReader, and StringWriter currently remain usable in cases where .NET would treat them as disposed. The gap is documented because adding state changes public object layout. Portable code should consider the wrapper dead after Close even though this revision does not enforce that rule.

BinaryReader and BinaryWriter

The binary wrappers define a stable Sharp Runtime protocol for the supported primitive set. Integers and IEEE floating-point values are encoded little-endian. Booleans use one byte. Strings are UTF-8 byte sequences prefixed with a 7-bit encoded byte length. The wrappers are not generic serializers: they do not write C++ object layouts, padding, vtables, pointers, or arbitrary structures.

AreaCurrent behaviorDifference to keep visible
Primitive orderLittle-endian fixed-width integers and floatsIndependent of host endianness; not raw object memory
Strings7-bit byte count followed by UTF-8No constructor-selectable encoding
CharactersUTF-8 decoded to charcs UTF-16 code unitsA supplementary scalar spans a surrogate pair and may cross read calls
PeekCharDecodes then restores positionRequires a seekable stream
Exact primitivesPremature end throws EndOfStreamExceptionReadBytes instead returns a shorter vector at clean EOF
DecimalReads the .NET 16-byte field orderThe member is compiled out under MSVC because Decimal needs native unsigned __int128

For seekable streams, ReadString validates that a declared length can fit in the remaining bytes before allocating the string buffer. ReadBytes similarly clamps its initial allocation to the remaining seekable length. Non-seekable streams cannot provide that early bound and are read incrementally.

Text readers and writers

TextReader and TextWriter are small porting-oriented bases. TextWriter supplies overloads for strings, C strings, characters, integers, floating-point values, booleans, and lines. The C-string overload exists to prevent a string literal from binding to the boolean overload through pointer-to-bool conversion. A null C string writes nothing; WriteLine(nullptr) still writes the platform line terminator.

StringReader stores a native string and returns its bytes as unsigned character values. It recognizes LF, CRLF, and lone CR as line terminators. StringWriter accumulates bytes in std::ostringstream. Neither type is a Unicode scalar iterator.

StreamReader’s encoding boundary

The current StreamReader reads one byte at a time and exposes that byte as a character value. It does not decode multibyte UTF-8, detect byte-order marks, select an encoding, or normalize newlines. This makes ASCII data straightforward and preserves single-byte Latin-1-style values, but a UTF-8 character outside ASCII appears as multiple reads. Use the Text component’s explicit encoding APIs when text can contain general Unicode.

StreamWriter writes the bytes in its input std::string and uses CRLF on Windows or LF elsewhere for WriteLine. Calling it “UTF-8” means the application supplies UTF-8 bytes by convention; the writer does not validate or transcode them.

Synchronous by design

The core Stream, FileStream, MemoryStream, text wrapper, and binary wrapper surfaces are synchronous. There are no base ReadAsync, WriteAsync, or cancellation overloads to mirror the full managed API. Networking components expose task-returning operations separately, often by running blocking work on native threads. Do not infer a unified async stream scheduler from the presence of Task elsewhere in the project.

Choosing a representation

SituationPreferred surfaceReason
Build or parse a bounded byte payloadMemoryStreamOwns storage, seeks, grows, and can return a copy
Read/write a file sequentially or by positionFileStreamRAII file ownership and familiar access/mode validation
Many small calls over a slow inner streamBufferedStreamBatches operations while preserving the inner interface
Existing caller-owned native bufferUnmanagedMemoryStreamNo copy, provided the lifetime is controlled
Fixed binary record formatBinaryReader/BinaryWriterExplicit little-endian primitives and length-prefixed UTF-8
ASCII-oriented linesStreamReader/StreamWriterSimple byte text with a familiar line API
Unicode text with known encodingText encoding API plus byte streamKeeps decoding and I/O as separate, explicit operations
Offsets beyond the 32-bit Stream modelRandomAccessUses longcs file offsets and lengths