Streams, Readers, and Writers
Understand byte transfer, capabilities, buffering, reader/writer protocols, and native lifetime before porting System.IO code.
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.
| Member | Base behavior | Subclass obligation |
|---|---|---|
Read(buffer, offset, count) | Pure virtual | Return zero at end-of-stream; validate the range and report partial reads honestly |
Close() | Pure virtual | End access to owned resources; repeated close should be safe where the concrete type promises it |
Length | Pure virtual | Return byte length or reject the operation for an unsupported/closed stream |
Write | Throws NotSupportedException | Override together with CanWrite |
WriteByte | Delegates to Write | Override only for a more direct implementation |
CanRead | true | Override for unreadable streams and when liveness changes the answer |
CanWrite | false | Override for every writable stream |
CanSeek | false | Override for every seekable stream |
Position | Throws NotSupportedException | Override getter and setter for seekable streams |
Seek | Computes a new position from Begin, Current, or End | May inherit it when Position and Length are correct |
SetLength | Throws NotSupportedException | Override only when resizing is meaningful |
ReadByte | Reads one byte through Read | Return -1 only at clean end-of-stream |
Flush | No-op | Override for buffering or an external device |
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
| Type | Storage | Read/write/seek | Ownership and close behavior |
|---|---|---|---|
MemoryStream | Owned std::vector<bytecs> | Readable and seekable while open; writable when constructed writable | Copies constructor bytes. ToArray and GetBuffer remain available after close |
FileStream | Owned std::fstream | Capabilities follow requested access and open state | Destructor closes the file. Operations requiring the file throw after Close |
BufferedStream | One shared read/write buffer over a Stream* | Delegates capabilities while open; accounts for unread or unwritten buffered bytes | Borrowed by default; ownsStream=true makes close/destruction close the inner stream |
UnmanagedMemoryStream | Caller-owned raw byte range | Fixed-capacity read/write/seek according to FileAccess | Never frees the buffer. Close invalidates stream operations and pointer access |
| Compression streams | ZLIB state plus another Stream* | Direction depends on compression mode | Belong to IO.Compression; inspect leaveOpen at the adapter boundary |
NetworkStream | Socket | Sequential network I/O; not a seekable file | Belongs 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.
| Wrapper | Default | leaveOpen=true | Wrapper state after Close |
|---|---|---|---|
BinaryReader | Closes the stream | Leaves the stream open | Disposed; later reader operations reject use |
BinaryWriter | Closes the stream | Leaves the stream open | Disposed; later writer operations reject use |
StreamReader | Closes an external stream; path constructor owns its FileStream | Leaves the stream open | Compatibility gap: the wrapper has no closed-state flag |
StreamWriter | Closes an external stream; path constructor owns its FileStream | Leaves the stream open | Compatibility gap: the wrapper has no closed-state flag |
StringReader | Owns a string copy | Not applicable | Inherits a no-op Close and remains readable |
StringWriter | Owns an ostringstream | Not applicable | Inherits a no-op Close and remains writable |
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.
| Area | Current behavior | Difference to keep visible |
|---|---|---|
| Primitive order | Little-endian fixed-width integers and floats | Independent of host endianness; not raw object memory |
| Strings | 7-bit byte count followed by UTF-8 | No constructor-selectable encoding |
| Characters | UTF-8 decoded to charcs UTF-16 code units | A supplementary scalar spans a surrogate pair and may cross read calls |
PeekChar | Decodes then restores position | Requires a seekable stream |
| Exact primitives | Premature end throws EndOfStreamException | ReadBytes instead returns a shorter vector at clean EOF |
| Decimal | Reads the .NET 16-byte field order | The 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
| Situation | Preferred surface | Reason |
|---|---|---|
| Build or parse a bounded byte payload | MemoryStream | Owns storage, seeks, grows, and can return a copy |
| Read/write a file sequentially or by position | FileStream | RAII file ownership and familiar access/mode validation |
| Many small calls over a slow inner stream | BufferedStream | Batches operations while preserving the inner interface |
| Existing caller-owned native buffer | UnmanagedMemoryStream | No copy, provided the lifetime is controlled |
| Fixed binary record format | BinaryReader/BinaryWriter | Explicit little-endian primitives and length-prefixed UTF-8 |
| ASCII-oriented lines | StreamReader/StreamWriter | Simple byte text with a familiar line API |
| Unicode text with known encoding | Text encoding API plus byte stream | Keeps decoding and I/O as separate, explicit operations |
| Offsets beyond the 32-bit Stream model | RandomAccess | Uses longcs file offsets and lengths |