Ruby / Foundations: values, methods, text, and valid objects / Lesson 6
Construct valid objects and control their public surface
Use initialize, instance variables, readers, validated operations, private helpers, and namespaces without promising security from visibility.
On this page
Lesson goals
- Keep every public state transition consistent with the object’s invariant.
- Distinguish instance state, class state, inheritance, and method visibility.
Initialize the state public methods rely on
Calling a class’s inherited Class#new, for example ArchiveCounter.new(1), normally allocates an instance and invokes its initialize method. Class.new itself creates an anonymous class. Instance variables such as @remaining belong to the constructed instance; reading an unset instance variable yields nil, which can postpone a setup defect until a later call. Prefer required constructor arguments and explicit validation over a partially usable object with several remembered setup steps. attr_reader generates a reader method, while attr_accessor also exposes a writer that may bypass your invariant. Convenience macros define real methods, so review their effect on the public API rather than treating them as harmless field declarations.
Expose an operation rather than unrestricted assignment
The archive counter accepts only a positive Integer capacity at construction, rejecting zero, negative values, fractions such as 0.5, and other input types. Each ordinary consume call removes one unit only while a whole unit remains, so the stored remaining count stays a nonnegative Integer and can reach zero. It has a reader but no generated writer. This establishes a local sequential protocol, not a durable allocation mechanism or a thread-safe transaction. Ruby’s reflective capabilities and visibility conventions are not security boundaries against untrusted code in the same process. The example demonstrates how ordinary callers can use a narrow interface, while real persistence, authorization, and concurrent access require their own owners and evidence. Expected output remains unexecuted because Ruby is absent.
class ArchiveCounter
attr_reader :remaining
def initialize(count)
unless count.is_a?(Integer) && count > 0
raise ArgumentError, 'positive Integer capacity required'
end
@remaining = count
end
def consume
return false if @remaining.zero?
@remaining -= 1
true
end
end
counter = ArchiveCounter.new(1)
puts counter.consume
puts counter.consume
puts counter.remaining
true
false
0
Expected output is true, false, 0. Construction accepts only positive Integer capacity; 0.5 and nonpositive or non-Integer inputs raise ArgumentError under the declared guard. The remaining count can reach zero after consumption. This is local sequential reasoning, not a concurrent-call or persistence guarantee. The script was not run.
Use visibility to communicate API intent
public, private, and protected affect ordinary method calls and should follow the intended collaboration model. Private helpers express implementation detail; protected supports particular peer access within the inheritance model. Exact receiver rules matter, so avoid presenting visibility as an access-control system for external users. A subclass can override behavior, and a constructor calling overridable methods can expose state before the subclass is ready. Prefer composition when an object merely uses another capability. Keep inheritance contracts explicit about valid inputs, return values, side effects, and whether overriding may weaken invariants.
Name responsibilities without creating global collisions
Modules can namespace classes as well as provide mixins. The example places a record-label object inside Archive and keeps its formatting helper private. It copies the incoming string so later mutation of the caller’s original string does not change this object’s stored text. That is a narrow String copy, not a deep-clone policy for arbitrary objects. Class methods such as self.build and instance methods such as display operate on different receivers. Avoid class variables for unrelated per-instance state, since inheritance and sharing can make their behavior surprising.
module Archive
class Label
def initialize(text)
@text = text.dup
end
def display
format_text
end
private
def format_text
"[#{@text.upcase}]"
end
end
end
original = String.new('catalog')
label = Archive::Label.new(original)
original << '-changed'
puts label.display
[CATALOG]
Expected output is [CATALOG]. Copying the input string isolates the later append to original. This is reasoned output only; no interpreter or visibility test ran.
Check the full public mutation surface
A safe constructor is insufficient if another public setter can install invalid state later. Review generated accessors, returned mutable references, subclass overrides, and callbacks that receive internal objects. Decide whether readers return snapshots or live values, and document ownership. Object.freeze can restrict direct mutation but is not automatic recursive validation or thread safety. Test every public transition and construction path in an isolated compatible interpreter when available. This course records source-level reasoning only and makes no claim of runtime object isolation, security enforcement, or durable state correctness.
Common errors and fixes
A generated writer permits an invalid count.
- Why it happens
- attr_accessor exposed mutation outside the validating operation.
- How to fix it
- Use a reader and explicit validated transitions where the invariant requires them.
- Prevent it next time
- Audit macros as part of the public method surface.
An unset instance variable causes a later nil failure.
- Why it happens
- The object was exposed before required state was established.
- How to fix it
- Initialize required values during construction or model pending state explicitly.
- Prevent it next time
- Exercise every construction path before allowing ordinary use.
Private methods are presented as protection against hostile same-process code.
- Why it happens
- API visibility was confused with a security isolation boundary.
- How to fix it
- Use proper trust and process boundaries for untrusted execution.
- Prevent it next time
- Separate developer-facing encapsulation from user authorization.
Check your understanding
1. Why might attr_reader still leak mutable state?
Show worked solution
It returns the referenced object, so callers may mutate a returned array or string even without a writer method. Choose a snapshot, immutable value, or documented shared ownership according to the API promise. Hiding assignment alone does not protect nested state or make concurrent access safe.
2. Does the ArchiveCounter prove a real inventory cannot be consumed twice?
Show worked solution
No. It is one sequential in-memory object with no persistence, cross-request identity, synchronization, or recovery. A real inventory owner must protect the authoritative invariant at its actual storage and execution boundary. Keep the class example’s encapsulation claim separate from those unimplemented service guarantees.
Key takeaways
- Construction and every public transition must preserve the same invariant.
- Encapsulation, ownership, security, and persistence are separate responsibilities.