In Ruby, what is StringIO, and why does reading from a StringIO right after writing to it return an empty string?
answer
- a String behind an IO-like interface
- one position for reads and writes
- rewind, or read #string
- frozen string opens read-only
- not an IO subclass; fileno is nil
basics
~20 sStringIO, from the stringio default gem, wraps a String in IO-like methods such as puts, gets and read. Reads and writes share one position, so after a write it sits at the end; call rewind first, or read #string.
solid answer
~40 s`StringIO` (require `"stringio"`, a default gem, 3.2.0 in Ruby 4.0) gives a `String` the methods of a stream: `write`, `puts`, `printf`, `gets`, `read`, `each_line`, `pos`, `rewind`. Reading and writing share **one position**, and after `io.puts "hi"` it sits at the end, so `io.read` returns `""` (and `gets` returns `nil`) until you call `rewind` or set `pos = 0`. `io.string` returns the whole underlying string regardless of the position. `StringIO.new` with no argument, or with an unfrozen string, opens read-write; a frozen string opens read-only, so `write` raises `IOError` ("not opened for writing"). It is not an `IO` subclass: `is_a?(IO)` is `false`, `fileno` is `nil`, and anything that needs a real file descriptor cannot use it.
code
ruby · 11 linesrequire "stringio"
io = StringIO.new
io.puts "hi"
io.read # => "" position is at the end
io.string # => "hi\n" the whole buffer
io.rewind
io.gets # => "hi\n"
ro = StringIO.new("seed".freeze)
ro.write("x") # IOError: not opened for writinggo deeper
Recall that StringIO makes a string behave like a stream, and that string returns everything written.
Explain the single shared position, why read returns empty after a write, and how a frozen string makes the stream read-only.
Know where StringIO cannot replace an IO, with no descriptor for child processes or class checks, and pick IO.pipe or a temporary file instead.
Push APIs to accept any object with the stream methods they call, so StringIO can substitute in tests without class checks.
## What StringIO is `StringIO` is a class that lets code which expects a **stream** work on a **string in memory**. It lives in the `stringio` library, a default gem that Ruby 4.0 ships at version 3.2.0, so `require "stringio"` works without a Gemfile entry. It implements the familiar stream methods: - writing: `write`, `<<`, `print`, `puts`, `printf`, `putc`; - reading: `read`, `gets`, `readline`, `each_line`, `getc`, `each_char`; - positioning: `pos`, `pos=`, `rewind`, `seek`, `eof?`, `truncate`; - the buffer itself: `string` and `string=`. Because Ruby code usually checks for behaviour rather than class, a `StringIO` can stand in wherever a method only calls stream methods on its argument: a parser that calls `each_line`, a report writer that calls `puts`, or `$stdout` during a test. ## One position for reading and writing A file opened read-write has a single **position**, the offset where the next read or write happens. `StringIO` copies that model exactly, and it is the source of the classic surprise: | Step | `io.pos` | `io.string` | Result | |---|---|---|---| | `io = StringIO.new` | 0 | `""` | | | `io.puts "hi"` | 3 | `"hi\n"` | returns `nil` | | `io.read` | 3 | `"hi\n"` | `""`: already at the end | | `io.gets` | 3 | `"hi\n"` | `nil`: end of stream | | `io.rewind` | 0 | `"hi\n"` | returns 0 | | `io.read` | 3 | `"hi\n"` | `"hi\n"` | Three details from the source are worth knowing precisely: 1. `read` with no length at the end of the stream returns an **empty string**, while `read(n)` with a positive `n` returns **`nil`**, the same contract as `IO#read`. 2. `gets` at the end returns `nil`, which is why `while (line = io.gets)` terminates. 3. `string` ignores the position entirely. When you only want what was written, as in a test, `io.string` is the right call and no rewind is needed. ## Modes and frozen strings `StringIO.new(string = "", mode)` accepts the same mode strings as `File.open`: | Mode | Clears the string? | Read | Write | |---|---|---|---| | `"r"` | no | yes | raises `IOError` | | `"w"` | yes | raises `IOError` | yes | | `"a"` | no | raises `IOError` | at the end only | | `"r+"` | no | yes | yes | With **no mode**, an unfrozen string opens read-write and a **frozen string opens read-only**. That matters under `# frozen_string_literal: true`: `StringIO.new("seed")` then wraps a frozen literal, and a later `write` raises `IOError` with "not opened for writing". Explicitly asking for a writable mode on a frozen string fails immediately with `Errno::EACCES`. Writes also **modify the string you passed in**, so pass a fresh buffer (`StringIO.new`, or `StringIO.new(+"")`) rather than a string other code still uses. ## Reusing a buffer: truncate does not rewind A common attempt to clear a `StringIO` between uses is `io.truncate(0)`. It empties the string but leaves the **position where it was**, so the next write lands past the end and `StringIO` pads the gap with NUL bytes: after writing `"abc"`, then `truncate(0)` and `write("x")`, the string is `"\u0000\u0000\u0000x"`. Clear it with `io.truncate(0)` **and** `io.rewind`, or simply create a new `StringIO`. The same position rule explains why `io.string = "new text"` is convenient: assigning a new string resets the position to the start. ## Where it is not an IO `StringIO` inherits from `Object`, not from `IO`. It includes `Enumerable` and two helper modules that supply `puts`, `printf`, `readline` and friends, but: - `StringIO.new.is_a?(IO)` is `false`, so code that checks the class rejects it; - `fileno` returns `nil` and `tty?` returns `false`, because there is no operating-system descriptor; - a child process cannot inherit it, and APIs that hand a descriptor to the kernel cannot accept it; - `sync` is always `true` and `flush` does nothing, since there is no buffer between the object and its string. When code needs a real descriptor in memory, `IO.pipe` provides a pair of connected `IO` objects; when it needs a real file, a temporary file does. ## Typical uses - capturing `$stdout` or an injected output stream in a test and asserting on `string`; - feeding input to code that reads a stream, with `StringIO.new("line 1\nline 2\n")`; - building a large string with `puts` and `printf` instead of repeated concatenation; - parsing an in-memory payload with `each_line`, exactly as if it had come from a file.
- What does StringIO#read(10) return at the end of the stream, compared with read?`read(10)` returns `nil` at the end, while `read` with no length returns `""`. It is the same contract as `IO#read`: a length-limited read signals end of stream with `nil`, a read-everything call returns whatever is left, which may be empty.
- Why can't you pass a StringIO as the output of a child process?A child process inherits operating-system file descriptors, and `StringIO` has none: its `fileno` is `nil`, because its data lives in a Ruby string. To collect a child's output in memory you need a real descriptor, such as the write end of `IO.pipe`, and read the other end.
saying these in an interview costs you the question
- StringIO is a subclass of IO
- StringIO keeps separate read and write positions
- You must flush a StringIO before its string shows the writes
- StringIO.new copies the string, so the original never changes
- Reading at the end of a StringIO raises EOFError from read