Skip to content
academia.sh

Lesson 01 / 09

Class Definition and Instance Variables

Classifying who wrote a method with four authors; instance variables living in the object and methods in the class; initialize being private; how a method written with attr_reader differs from one written with def.

Published Updated

In this lesson

  • Classify the author of a method as programmer, ancestor, code or nobody
  • Measure that instance variables live in the object and methods live in the class, using instance_variables and instance_methods
  • Tell apart a method written with def from a method generated with attr_reader in the same class, using iseq
Contents

The previous course measured where a message finds its answer: in the receiver’s class, in a mixed-in module, in a given block, or in the syntax itself (M13/K01). It closed with one question left open. account.balance_text, point.x, settings.color and proxy.sort are called in the same way, yet the bodies of the four come from four different routes. It is known that a method lives in a particular owner; it is not known who wrote that definition.

This course measures that question. The first lesson takes the smallest form of a class: where do instance variables live, where do methods live, and how is the author of a method classified?

Four Authors and the Measurement Core

Every lesson of the course asks the same question: who wrote this method? The answer is one of four categories. The programmer is the person who wrote the body with def in the receiver’s own class. An ancestor left the definition higher in the chain, in a superclass or a module. Code is the machinery that produces a definition at run time through a call: attr_reader, define_method, Struct. Nobody is the case where the chain holds no definition at all and the message is caught by method_missing. In the output these four names are written in ASCII: programmer, ancestor, code, nobody.

CM1. In this course the “author” is the category that says where the definition of a method came from. What makes the classification is a rule, not a label: if the receiver’s singleton class has no definition, the author is nobody; if the owner of the definition is neither the receiver’s class nor its singleton class, the author is an ancestor; if the iseq of the definition is nil or its label starts with block, the author is code; otherwise the author is the programmer. The rule reads the form of a definition, not its intent. This sentence is the common face of several bounding measurements in the course.

The rule lives in the file author.rb. It is not rewritten during the course; every lesson loads it in the same form. The order inside the file matters. First it asks whether the definition exists, because the owner of a method with no definition cannot be asked. Then it asks for the owner, because a definition whose owner is not the receiver’s class has already come from somewhere else. Last it looks at how the definition was produced: only for definitions that sit in the receiver’s own class is it necessary to separate a body written with def from the product of a call.

# author.rb — a helper that classifies who wrote a method

# Classification asks in order: is there a definition in the receiver's chain at all,
# then does the receiver's own class carry it, then how was the definition produced.
def author(receiver, name)
  chain = receiver.singleton_class
  unless chain.method_defined?(name) || chain.private_method_defined?(name)
    return ["nobody", "no definition"]
  end

  definition = chain.instance_method(name)
  owner = definition.owner
  place = owner == chain ? "singleton class" : owner.to_s
  return ["ancestor", place] unless owner == receiver.class || owner == chain

  iseq = RubyVM::InstructionSequence.of(definition)
  return ["code", "#{place}, no ruby body"] if iseq.nil?
  return ["code", "#{place}, block body"] if iseq.label.start_with?("block")

  ["programmer", "#{place}, def body"]
end

author(receiver, name) returns an array of two values: the name of the author and the evidence. The evidence states the owner of the definition and how the body was written. The second file, which runs nine observations together, is authors.rb. Each observation is a method called in the same way; the difference lies only behind the call.

# authors.rb — nine observations: who wrote this method
require_relative "author"

module Reportable
  def report = "report: #{balance_text}"
end

class Account
  include Reportable

  def initialize(owner, balance)
    @owner = owner
    @balance = balance
  end

  def balance_text = "#{@owner} #{@balance}"
  def net = @balance - fee

  private

  def fee = 2
end

class TermDeposit < Account
end

module Lock
  def unlock = "lock opened, #{super}"
end

class Safe
  prepend Lock
  def unlock = "safe opened"
end

Point = Struct.new(:x, :y)

class Settings
  %i[color width].each do |name|
    define_method(name) { "#{name} default" }
  end
end

class Proxy
  def initialize(target)
    @target = target
  end

  def method_missing(name, *args, &block)
    return super unless @target.respond_to?(name)

    @target.public_send(name, *args, &block)
  end

  def respond_to_missing?(name, include_private = false) = @target.respond_to?(name, include_private) || super
end

class Model
  def self.field(name, default)
    define_method(name) { instance_variable_get("@#{name}") || default }
  end
end

class Product < Model
  field :name, "unnamed"
  field :price, 0
end

account = Account.new("amy", 100)
observations = {
  "state reading" => [account, :balance_text, -> { account.balance_text }],
  "superclass"    => [TermDeposit.new("tom", 50), :balance_text, -> { TermDeposit.new("tom", 50).balance_text }],
  "mixin"         => [account, :report, -> { account.report }],
  "private helper" => [account, :fee, -> { account.net }],
  "prepended"     => [Safe.new, :unlock, -> { Safe.new.unlock }],
  "member reader" => [Point.new(3, 4), :x, -> { Point.new(3, 4).x }],
  "generated"     => [Settings.new, :color, -> { Settings.new.color }],
  "proxy"         => [Proxy.new([3, 1, 2]), :sort, -> { Proxy.new([3, 1, 2]).sort }],
  "declaration"   => [Product.new, :price, -> { Product.new.price }],
}

tally = { "programmer" => 0, "ancestor" => 0, "code" => 0, "nobody" => 0 }

printf("%-15s%-11s%-27s%s\n", "observation", "author", "evidence", "result")
observations.each do |name, (receiver, message, call)|
  kind, evidence = author(receiver, message)
  tally[kind] += 1
  printf("%-15s%-11s%-27s%s\n", name, kind, evidence, call.call.inspect)
end

puts
tally.each { |kind, n| printf("%-11s%d\n", kind, n) }
puts "total observations: #{tally.values.sum}"
observation    author     evidence                   result
state reading  programmer Account, def body          "amy 100"
superclass     ancestor   Account                    "tom 50"
mixin          ancestor   Reportable                 "report: amy 100"
private helper programmer Account, def body          98
prepended      ancestor   Lock                       "lock opened, safe opened"
member reader  code       Point, no ruby body        3
generated      code       Settings, block body       "color default"
proxy          nobody     no definition              [1, 2, 3]
declaration    code       Product, block body        0

programmer 2
ancestor   3
code       3
nobody     1
total observations: 9

Of the nine observations, a programmer wrote 2 and an ancestor wrote 3; code produced 3; in 1 there was no definition and method_missing caught the message. These numbers do not change during the course. The programmer wrote the state reading and private helper rows: the bodies of both sit inside Account as a def. The ancestor wrote the superclass, mixin and prepended rows; the three definitions sit away from the receiver, in Account, in Reportable and in Lock. In the member reader, generated and declaration rows a call produced the definition. In the proxy row there is no definition and the result still came back.

The share of this lesson is the first programmer row: state reading. The following lessons open the ancestor, code and nobody authors in turn; this lesson measures the simplest structure a programmer writes, a class and its instance variables.

State in the Object, Behavior in the Class

The initialize method of the Account class takes two values and assigns both to instance variables. An instance variable is a variable that starts with @ and is bound to an object (M13/K01). The file below redefines a small slice of the class, because loading authors.rb prints the table of nine rows; this slice runs without repeating it.

# state.rb — instance variables live in the object
class Account
  def initialize(owner, balance)
    @owner = owner
    @balance = balance
  end
end

account = Account.new("amy", 100)
p account.instance_variables
p account.instance_variable_defined?(:@balance)
p Account.instance_methods(false)
begin
  account.balance
rescue NoMethodError => e
  puts e.class
end
[:@owner, :@balance]
true
[]
NoMethodError

instance_variables listed the two variables in the order of assignment: @owner and @balance. The variables live not in the Account class but in the account object. Account.instance_methods(false), on the other hand, returned an empty array: the class has no public method. initialize is private, and the false argument asks only for the class’s own definitions. That is why the call account.balance raised NoMethodError: the variable is inside the object, but because no method was written to read it, it is not visible from outside.

CM2. In this lesson an “instance variable” is a variable that is bound to an object and starts with @. That a variable exists in an object does not mean that a method reading it exists. State lives in the object and behavior lives in the class; the two sides are tied together through a name such as @balance.

An instance variable does not come into being in the object at all until it is assigned. The file below measures this: the variable that is read has not been assigned yet.

# unassigned.rb — an unassigned instance variable reads as nil and is not listed
class Account2
  def read_value = @missing
  def write_value(value) = @missing = value
end

object = Account2.new
p object.read_value
p object.instance_variables
object.write_value(5)
p object.read_value
p object.instance_variables
nil
[]
5
[:@missing]

The unassigned variable was read as nil, but it did not enter the instance_variables list. After the assignment, the variable was both read and listed. Reading creates nothing in the object; only assignment does. This behavior also creates a difference among the objects of a class such as Account2: an object on which write_value has been called carries the variable @missing, and an object on which it has not been called does not. Even when two objects come from the same class, their lists of instance variables need not be the same; the list reflects the history of the object, not of the class. A read that raises no error can for this reason hide a typing mistake: if @balanse is written, Ruby quietly gives nil.

Two Objects, One Class

That state lives in the object and behavior in the class is measured with two objects. The file below produces two accounts from the same class and compares two things: the owner of the method and the names of the instance variables.

# two.rb — two objects share the same method, they do not share the values
class Account
  def initialize(owner, balance)
    @owner = owner
    @balance = balance
  end

  def balance_text = "#{@owner} #{@balance}"
end

a = Account.new("amy", 100)
b = Account.new("tom", 50)
puts a.balance_text
puts b.balance_text
p a.method(:balance_text).owner == b.method(:balance_text).owner
p a.instance_variables == b.instance_variables
p Account.instance_of?(Class)
p a.class
amy 100
tom 50
true
true
true
Account

The same method gave two separate results on two objects: amy 100 and tom 50. The owner of the method is the same for both objects, and the names of their instance variables are the same too; only the values differ. The method body is written once, in the class, and on every call it reads the variables of the object that receives the call. Account.instance_of?(Class) gave true: the line class Account sets up a constant named Account, and what the constant points to is an object of type Class. a.class returns this object. A class is the place where methods live, and it is also an object that produces objects.

This distinction has a practical consequence. The number of methods does not grow with the number of objects: when a thousand accounts are created, balance_text still sits in a single place, once. What multiplies per object is only the values of the instance variables. Asking for the author of a method therefore always points at the class or at the chain; the object itself carries only state, and the measurement that asks for the author does not look at its variables.

initialize Is Private and Checks the Arity

The call Account.new("amy", 100) produces a new object and calls the initialize method on this object. It is new that calls initialize, not code outside. The class keeps it as a private method; code cannot call it by writing account.initialize(...). The file below uses the full definition of Account from the core, because the private method fee is measured here as well.

# private_init.rb — initialize is a private method
require_relative "author"

class Account
  def initialize(owner, balance)
    @owner = owner
    @balance = balance
  end

  def balance_text = "#{@owner} #{@balance}"
  def net = @balance - fee

  private

  def fee = 2
end

account = Account.new("amy", 100)
p account.respond_to?(:initialize)
p Account.private_instance_methods(false).sort
p author(account, :initialize)
begin
  Account.new("a")
rescue ArgumentError => e
  puts e.class
end
false
[:fee, :initialize]
["programmer", "Account, def body"]
ArgumentError

respond_to?(:initialize) gave false: a private method is not visible when asked from outside. private_instance_methods(false) listed the two private methods of the class: fee and initialize. For initialize, author found the programmer, because a def in the class wrote the body. When Account.new("a") was called with a single argument it raised ArgumentError: initialize expects two arguments, and Ruby checks the count at call time.

CM3. In this lesson the “arity” is the number of arguments a method accepts; Account.new takes this number from the initialize definition. initialize(owner, balance) asks for two arguments; that is why Account.new("a") raised ArgumentError and returned no object. Ruby checks the number before the body runs, so the @owner assignment did not happen either. The arity thus also bounds the first state of the object: a constructor that sets up two instance variables does not run without two values. A variable that is not assigned inside initialize, and not assigned later by another method, does not exist in that object at all; the constructor of the class is therefore the place where the first state of the object is set up and the most reliable point for seeing the variable names together.

Reopening a class means writing one more class block with the same class name. The existing definition is not deleted; the new one is added (M12/K03). The definition of a class therefore does not have to sit in a single place; the measurement below reopens the class.

The Bounding Measurement

Two authors can live together in the same class, in the same owner. Account is reopened and attr_reader :owner is added. attr_reader generates a reader method: the owner method returns @owner. In the earlier courses (M08/K01) it was measured what the compiler adds to a class. Here the question is different: of the two methods sitting in the class, one was written by a def and the other was produced by a call.

# reopened.rb — two authors in the same class: def and attr_reader
require_relative "author"

class Account
  def initialize(owner, balance)
    @owner = owner
    @balance = balance
  end

  def balance_text = "#{@owner} #{@balance}"
  def net = @balance - 2
end

class Account
  attr_reader :owner
end

account = Account.new("amy", 100)
p author(account, :owner)
p author(account, :balance_text)
p Account.instance_methods(false).sort
[:owner, :balance_text].each do |name|
  definition = Account.instance_method(name)
  puts "#{name} #{definition.owner} #{definition.source_location.nil?} #{RubyVM::InstructionSequence.of(definition).nil?}"
end
["code", "Account, no ruby body"]
["programmer", "Account, def body"]
[:balance_text, :net, :owner]
owner Account false true
balance_text Account false false

author(account, :owner) found code, and author(account, :balance_text) found the programmer. The owner of both is Account, and both appear in the instance_methods(false) list: [:balance_text, :net, :owner]. Knowing the owner does not tell the author. The last loop shows where the distinction comes from. source_location is not nil for either method: the line of the attr_reader call is recorded as its source location. The distinction is made by iseq. For balance_text, written with def, there is an iseq; for owner, generated with attr_reader, it is nil. The rule therefore looks at iseq, not at the source location.

This measurement shows that the owner does not determine the author: two methods sat in the same owner and came out with two different authors. The same measurement also shows that a class is not an identity card. Account does not carry a single author; the author of each method is measured separately. At the class level there is no category that says “the programmer wrote this class”.

Summary

  • The measure of the course is who wrote a method: the author may be the programmer, an ancestor, code or nobody, and the decision is made by the rule in author.rb.
  • Of the nine observations, a programmer wrote 2 and an ancestor wrote 3; code produced 3; in 1 there was no definition and method_missing caught the message.
  • Instance variables live in the object and methods live in the class; if there is no reader, account.balance raises NoMethodError, and an unassigned variable reads as nil and does not enter instance_variables.
  • initialize is a private method and checks the arity; Account.new("a") raises ArgumentError.
  • The author of this lesson is the programmer: in the state reading row, a def inside Account wrote balance_text.
  • In the same class, a method written with def comes out as the programmer and a method generated with attr_reader comes out as code; the owner of the two is the same, and iseq makes the distinction.

Next Step

balance_text is written in Account, yet an empty subclass named TermDeposit can also call the same method and get an answer. There is not a single line of definition in the subclass. Who brings the method to the subclass, and what changes if the same method is rewritten in the subclass? The next lesson, Inheritance and super, measures this.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close