<SYSTEM_TASK:>
Given the following text description, write Python code to implement the functionality described below step by step
<END_TASK>
<USER_TASK:>
Description:
 Lecture 13
Monday, October 23rd 2017
Last time
Step1:  To iterate over an object x, Python automatically calls iter(x) (i.e. x.__iter__). 
An iterable is something which, when iter is called on it, returns an iterator.
(1) If __iter__ is defined, it is called to implement an iterator.
(2) If not, __getitem__ is called starting from index 0.
(3) If no __iter__ and no __getitem__ then raise a TypeError.
Any Python sequence is iterable because sequences implement __getitem__. The standard sequences also implement __iter__; for future proofing you should too because  (2) might be deprecated in a future version of Python.
We know that for operates on iterables
Step2:  What's actually going on here?
Step3:  We can completely abstract away a sequence in favor of an iterable (i.e. we don't need to support indexing anymore)
Example 14-4 in Fluent Python
Step4:  While we could have implemented __next__ in Sentence itself, making it an iterator, we will run into the problem of "exhausting an iterator". 
The iterator above keeps state in self.index and we must be able to start anew by creating a new instance if we want to re-iterate. Thus the __iter__ in the iterable simply returns the SentenceIterator.
From Fluent Python ("Sentence Take #2
<ASSISTANT_TASK:>
Python Code:
import reprlib
class Sentence:
    def __init__(self, text): 
        self.text = text
        self.words = text.split()
        
    def __getitem__(self, index):
        return self.words[index] 
    
    def __len__(self):
        #completes sequence protocol, but not needed for iterable
        return len(self.words) 
    
    def __repr__(self):
        return 'Sentence(%s)' % reprlib.repr(self.text)
# Sequence'
s = Sentence("Dogs will save the world.")
print(len(s), "   ", s[3], "   ",  s)
min(s), max(s)
list(s)
Explanation: Lecture 13
Monday, October 23rd 2017
Last time:
Data structures motivation
Abstract data types
Sequences
Linked lists
This time:
Iterators and Iterables
Trees, B-trees, and BSTs
From pointers to iterators
One can simply follow the next pointers to the next position in a linked list. 
This suggests an abstraction of the position to an iterator.
Such an abstraction allows us to treat arrays and linked lists with an identical interface.
The salient points of this abstraction are:
- The notion of a next
- The notion of a first to a last
We already implemented the sequence protocol.
Now we suggest an additional abstraction that is more fundamental than the notion of a sequence: the iterable.
Iterators and Iterables in Python
Just as a sequence is something implementing __getitem__ and __len__, an iterable is something implementing __iter__. 
__len__ is not needed and indeed may not make sense.
python
len(open('fname.txt')) # File iterator has no length
Example 14-1 in Fluent Python: The Sentence sequence and shows how it can be iterated upon.
End of explanation
for i in s:
    print(i)
Explanation: To iterate over an object x, Python automatically calls iter(x) (i.e. x.__iter__). 
An iterable is something which, when iter is called on it, returns an iterator.
(1) If __iter__ is defined, it is called to implement an iterator.
(2) If not, __getitem__ is called starting from index 0.
(3) If no __iter__ and no __getitem__ then raise a TypeError.
Any Python sequence is iterable because sequences implement __getitem__. The standard sequences also implement __iter__; for future proofing you should too because  (2) might be deprecated in a future version of Python.
We know that for operates on iterables:
End of explanation
it = iter(s) # Build an iterator from an iterable
while True:
    try:
        nextval = next(it) # Get the next item in the iterator
        print(nextval)
    except StopIteration:
        del it # Iterator is exhausted.  Release reference and discard.
        break
Explanation: What's actually going on here?
End of explanation
class SentenceIterator: # has __next__ and __iter__
    def __init__(self, words): 
        self.words = words 
        self.index = 0
        
    def __next__(self): 
        try:
            word = self.words[self.index] 
        except IndexError:
            raise StopIteration() 
        self.index += 1
        return word 
    def __iter__(self):
        return self
    
class Sentence: # An iterable b/c it has __iter__
    def __init__(self, text): 
        self.text = text
        self.words = text.split()
        
    def __iter__(self):
        return SentenceIterator(self.words) # Returns an instance of the iterator
    
    def __repr__(self):
        return 'Sentence(%s)' % reprlib.repr(self.text)
s2 = Sentence("What is data science?")
for i in s2:
    print(i)
s2it=iter(s2) # Make the iterable an iterator
print(next(s2it), "\n\n") # Get the next entry
s2it2=iter(s2) # Reset the iterator
print(next(s2it), "   ", next(s2it2)) # Get the next entry of s2it and s2it2
Explanation: We can completely abstract away a sequence in favor of an iterable (i.e. we don't need to support indexing anymore)
Example 14-4 in Fluent Python:
End of explanation
min(s2), max(s2)
Explanation: While we could have implemented __next__ in Sentence itself, making it an iterator, we will run into the problem of "exhausting an iterator". 
The iterator above keeps state in self.index and we must be able to start anew by creating a new instance if we want to re-iterate. Thus the __iter__ in the iterable simply returns the SentenceIterator.
From Fluent Python ("Sentence Take #2:  A Classic Iterator"):
A common cause of errors in building iterables and iterators is to confuse the two. To be clear: iterables have an __iter__ method that instantiates a new iterator every time. Iterators implement a __next__ method that returns individual items, and an __iter__ method that returns self.
min() and max() also work even though we no longer satisfy the sequence protocol.
min and max are pairwise comparisons and can be handled via iteration.
The take home message is that in programming with these iterators we don't need either the length or indexing to work to implement many algorithms: we have abstracted these away.
End of explanation
<END_TASK>