[Edu-sig] Some design patterns with iterators

Kirby Urner urnerk@qwest.net
Sun, 30 Mar 2003 23:16:22 -0800


At 07:44 PM 3/30/2003 -0500, Guido van Rossum wrote:
....

Your comments highly welcomed and greatly appreciated!

>--
>
>That's a contorted example.  Why not change this a bit so that instead
>of __call__ it has a next method that raises StopIteration when
>exhausted, and an __iter__ method returning self?

Agreed.  Students should realize that objects don't need to be wrapped by
iter() in order to perform as iterators.  It's sufficient to define a next()
method internally, and then assign the object itself as the return of __iter__
-- as you point out.

It's also possible to equate __call__ to next, so if the programmer *does*
happen to wrap the object inside iter(), even with a sentinel, it won't 
break --
although the sentinel is irrelevant in this pattern, because now the test
raises an exception instead.

class Fib2(object):
     def __init__(self,start=0,next=1,test=lambda x: False):
         # note revised default for test -- previous default of
         # 'False' was not callable
         self.start = start
         self.next  = next
         self.test  = test

     def next(self):
         self.start,self.next = self.next, self.start + self.next
         if self.test(self.start): raise StopIteration
         return self.start

     __call__ = next  # makes object callable

     def __iter__(self):
         return self


def testfunc(max):
     """a factory for test functions"""
     def t(x):
         return x > max
     t.__doc__ = "stop when x > %s" % max
     return t

t1 = testfunc(1000)
t2 = testfunc(5000)

def looper(iterable):
     for i in iterable:
         print i,
     print  # newline when done

def tests():
     print t1.__doc__
     looper(Fib2(test=t1))  # note:  no iter() wrapper -- Fib2 is already 
iterable
     print t2.__doc__
     looper(iter(Fib2(test=t2),-1))  # but we can wrap it without problems
     print t2.__doc__
     looper(iter(Fib2(test=t2)))     # with or without a sentinel

Having a sentinel where it's not needed is of course a sign of programmer 
confusion.
But one might argue the object itself is "more robust" this way.


> >   >>> def fibfunc(test):
> >          data = [1,1]
> >          while not test(data[0]):
> >             yield data[0]
> >             data[0],data[1] = data[1],data[0]+data[1]
> >          return
>
>Comment here: instead of using a two-element list (which could just as
>well be a tuple!), use two variables.  Faster and clearer IMO:
>
>   def f(test):
>    last, next = 1, 1
>    while not test(last):
>      yield last
>      last, next = next, last+next

Yes, clearer.  The list-based approach was left over from a version which
makes the initial two-value seed a parameter as in:

def iterseq(seed=[0,1],test=lambda x: False):
    # but even so, it's easier to read with two variables:
    last,next = seed
    while not test(last):
        yield last
        last, next = next, last+next

With this new freedom we can generate the Lucas Numbers, with seed
[2,1] instead of [1,1]:
http://www.mcs.surrey.ac.uk/Personal/R.Knott/Fibonacci/lucasNbs.html

def tests():
     #...
     print "Lucas Numbers"
     looper(iterseq([2,1],t1))

 >>> iters.tests()
...
Lucas Numbers
2 1 3 4 7 11 18 29 47 76 123 199 322 521 843

A modification lets us iterate until a last/next ratio is reached through
convergence -- need to be careful here, as infinite loops often result from
equating floating point numbers.

def iterseq2(seed=[1,1],test=lambda x,y: False):
    # variation, where test looks at both last and next
    last,next = seed
    while not test(last,next):
        yield "%s/%s" % (last,next)
        last, next = next, last+next

# probaby overkill to wrap a function here, but I'm wanting to *not* rely on a
# global value for phi, nor do I want to keep re-evaluating a square root with
# every test -- so I pass it in explicitly and build a function around it.

def testratio(terminus):
     def t(inta,intb):
          return (intb/inta) == terminus # uses new division
          # it'd be safer to test for abs(difference) < tolerance
     return t

t3 = testratio(0.5*(1+pow(5,.5)))  # phi = (1 + sqrt(5))/2

def tests():
     # ...
     print "Converging..."
     looper(iterseq2([1,1],t3))   # Fibonacci seed, convergence test

Converging...
1/1 1/2 2/3 3/5 5/8 8/13 13/21 21/34 34/55 55/89 89/144 144/233 233/377 
377/610 610/987 987/1597 1597/2584 2584/4181 4181/6765 6765/10946 
10946/17711 17711/28657 28657/46368 46368/75025 75025/121393 121393/196418 
196418/317811 317811/514229 514229/832040 832040/1346269 1346269/2178309 
2178309/3524578 3524578/5702887 5702887/9227465 9227465/14930352 
14930352/24157817 24157817/39088169 39088169/63245986 63245986/102334155

The Lucas Number next/last ratios likewise converge to phi.


>Are your students familiar with functions as first-order objects at
>this point?  It would be simpler if you could simply pass the limit as
>the argument to Fib() rather than having to construct a test function
>first.  Sure, a test function allows more freedom, but the API design
>here doesn't actually make it much easier to do something different in
>the test function -- e.g. another useful termination test would be to
>produce the first N numbers, but that would require the test function
>to be stateful, and the API doesn't promise that test() is only called
>once per iteration.

Yes, I'm sort of demonstrating two concepts -- iterators, and the fact that
functions may be passed as parameters.  There's more convolution as a result.

Plus the above example points up a weakness in this design:  the passed-in
function is relying on a hard-wired set of arguments, is in test(last),
whereas we might want access to both last and next for our test.  Our only
access to the local scope, within the generator function, is based on the
arguments to test() -- a limit on our freedom.

I do find it interesting that objects don't suffer as much from this 
limitation,
as one has access to all the object's instance variables through 'self' -- so
it's possible to write a top-level test function knowing that 'self' is the
only argument needed -- yet internal to the test function, you may access
self's scope at will:

class Fib3(object):
     def __init__(self,start=0,next=1,test=lambda x: False):
         self.start = start
         self.next  = next
         self.test = test

     def next(self):
         self.start,self.next = self.next, self.start + self.next
         if self.test(self):  # single parameter sufficient to access __dict__
              raise StopIteration
         return self.start

     __call__ = next

     def __iter__(self):
         return self

def testratio2(terminus):
     def t(obj):  # obj will be the self of the enclosing object
          return obj.next/obj.start == terminus # uses new division
     return t


But we're just getting more and more convoluted here (useful learning on
my end though) ...

Kirby