 Another sequence type we haven't yet discussed is called a range. A range is an immutable sequence of number values, but what makes a range differ from say a tuple that happens to consist of just number values is that a range doesn't actually store necessarily all of its values in memory. What does that mean? Well, we create a range using the range constructor found in the built-in namespace. So here we invoke the range constructor and we pass to it two arguments, two number values. The first is where the range is going to start. The second specifying where the range ends, though actually it specifies one past the end of the range. So if you write range 630, what you get is a range representing the value 6 up to and including 29, not 30. So it's kind of strange because of the asymmetry. The first argument is included in the range, whereas the second argument isn't. It's actually one past. It's a bit stranger first, but then when you work with it in practice you'll realize there actually is a good justification for this. It actually does usually better reflect what we really want in most cases. In any case, you also see here the example of range with the arguments 0 and 10. So that's a range representing the value 0 up to and including 9. Again, one less than the second argument. In the third example, we provide just one argument. So the starting position is assumed by default to be 0. So this is actually again the range from 0 up to and including 9. And our last example, range with the arguments negative 6 and negative 2, that's a range that starts at negative 6 and goes up to and includes negative 3. Again, one less than the second argument. Now if we were to create the equivalent tuple of some range, well that tuple would store each and every value. A range object in contrast only has to store its starting value and its end value, or more accurately one past its last value. What happens then when you look up the value at a specified index of a range, Python just figures out what the value should be from logic. So here for example, we create a range starting at 100 going up to but not including 200, and sign that to x, and then we retrieve the value of this range at index 30. The range object returns the correct value 130 simply by taking its starting value 100 and adding 30 to it. Again, if the same range were expressed as a tuple, we'd have the whole sequence of values stored in memory, first with 100, then 101, then 102, 103, 104, 105, and that obviously takes up a lot more space. It's bad enough when you're talking about a sequence of 100 numbers or so, but just imagine you're talking about 1,000 numbers or a million or more. When we create a range, we can optionally specify a third argument, which is known as the step. The step of a range by default is positive 1 and it denotes that from the start value, you get the next value by incrementing by 1. If the step is something else like positive 3, then you get the next value by incrementing by 3 rather than just 1. Or if the step is negative, which is perfectly possible, then you get the next value by decrementing. So in these examples, we first have range with the arguments 4, 14, and 3. That's a starting value of 4, an end value of 14, one pass, the last value is 14, and a step of positive 3. So the sequence goes 4, 7, 10, 13, and then there's no 16 because 16 is greater than or equal to the end value, 14. In the second range example, we have a starting value of positive 5 and an end value of negative 5 and a step of negative 2. So we start out at 5 and go to 3, 1, negative 1, and 3. And negative 5 itself is not included because negative 5 is less than or equal to the specified end value, which is negative 5. Negative 5, of course, is equal to negative 5. And then finally, in our third example, a range starting at negative 26, going to negative 48 with a step of negative 5. That gets us a range of negative 26, negative 31, negative 36, negative 41, and negative 46, and then we don't go up to negative 51 because negative 51 is less than or equal to the end value of negative 48. So note the key difference between a positive step and a negative step. With a positive step, the values are included until they are greater than or equal to the terminated value, whereas with a negative step, values are included until they are less than or equal to the end value. And understand that you can actually end up creating an empty range where the Python won't stop you. It's not an error to create a range that has the starting value 10, a termination value of 20, and a step of negative 1. This will end up being an empty range because the first value 10 itself is less than or equal to the terminated, which is positive 20. So if you were concerned that a range like this would start at 10 and keep decrementing off into infinity because it never crosses the threshold of positive 20, well, that's not actually how it works. It just starts out testing that, no, it is less than or equal to the terminated so it doesn't even have any values at all. And this, of course, is not a sort of range you would deliberately create, but it is a logical possibility. What's called an iterator is not a sequence per se, though in many circumstances it can be provided as a substitute to a normal sequence. What an iterator is is really a wrapper around some other sequence, some actual sequence. And the idea of an iterator is it's basically like a placeholder that you can use to iterate through an entire sequence, to go one by one from the start to the end of that sequence. So say we have this three-item list, which we assign to A, and from the list we can create an iterator over the list using the iter method of the list type, and we assign that to I. And with the iterator then we can invoke its next method. And each call to next will return the next value of the sequence which the iterator wraps over, starting from the first value. So the first time we call next it returns six, which is the first item in the list. The second call to next will return the second item of the list, and the third call to next will return the third item of the list. And then if we call next one more time, well, there is no fourth value in the list. We've exhausted the list, so the call to next will raise a stop iteration exception. So a couple key things to understand here is that the iterator object itself does not have a copy of the values of the list. It simply wraps the list. It has a reference to the list itself, and the iterator has internal state, denoting where it currently is in the sequence. Basically what index is next. So be clear that each iterator has its own place marker over the sequence. If you take one sequence and you from it produce multiple iterators, well, those iterators have different place markers. So here again if we create an iterator which we assign to I, and then invoke next on the iterator we get the first item of the sequence, which is again is six, but then we create some second iterator, which we assign to I2. Well, what we do with one iterator won't affect what happens with the other. So next invoked on the original iterator will return two, because that's what's next for that iterator, but the new iterator if we invoke next will return six, which is the first item of the list. It'll start from the beginning, because it represents a separate iteration over the list. Now we can also create iterators over maps. So here we have this dictionary with two items, key 71 with the value moose and a key North Dakota with the value 11, assign that to B, and then create an iterator over the dictionary, which we assign to I. We invoke next on the iterator twice, getting the two keys of the dictionary in no particular order, and then the third call to next raises a stop iteration exception, because we've exhausted the iterator. We've iterated through all the items in the dictionary. So with map iterators, be clear on those two points. First, what you're iterating over are just the keys, not the values, and second, you iterate over the keys in no particular order. Now again, the method names in Python that begin and end with double underscores, what that indicates is that generally you're not intended to invoke those methods directly. The proper way to invoke iter and next is to pass the objects to the built-in functions of the same name, without of course the double underscores. I'm not really sure why Python did this. It would make more sense to me if you just had methods called iter and next with no double underscores, but for whatever reason, this is how it is. The last thing we'll say about iterators is that in Python, iterators are generally what we call live or active, meaning that if the underlying sequence object changes, then those changes may show up in the iterators which you have previously created from that object. So here again, we have the list with three values, we assign it to A, we create the iterator from that object, and we invoke next on the iterator getting the first item in the list, 6, but then if we were to modify the second item in the list, here assigning it the value 77, and then invoke next on the iterator, that call to next doesn't return 2, which was the state of the sequence at the time the iterator was created, instead it returns 77, which is the new state of the second value in the list. So really what's going on with these iterators is they're really just blindly advancing through the indices each time next is called, and it's only until they go past the last valid index of the underlying sequence does it then raise the stop-iteration exception. However, once next on an iterator has raised stop-iteration, then all subsequent calls to next on the iterator will also raise stop-iteration exception. Basically, once an iterator is exhausted, it's considered dead. So even if you were to update the underlying collection and fix a tack on new items at the end, that's not going to make the iterator come back to life and start returning those values. It'll just keep throwing stop-iteration exception. The other thing to watch out for is that with dictionaries in the middle of iterating through all the values of a dictionary, yeah, the dictionary can be updated with new values, and the iterator will just blindly continue on, and you can actually also add and remove whole items from the dictionary, and any iterators previously created on that dictionary will blindly operate as if nothing has happened. However, if you call next on an iterator over a dictionary, where the dictionary has a different number of items than it had when the iterator was created, then the call to next will raise an exception. So dictionary iterator is still good, even if you modify the underlying dictionary, as long as the number of items is what the iterator expects each time you call next on the iterator. It's kind of some weird behavior, to be honest. I'm not really sure what justifies it. So now you're probably wondering what's so cool about iterators. So one of the most important uses is with Python's for-in loop. Python doesn't have a regular for-loop like JavaScript does. It only has for-in, which is written for-target. That is the name of some variable to be assigned to. The reserved word in, which in this context has nothing to do with the reserved word in, which is the contains operator that's totally separate here, and then followed by an iterable, which is some expression which returns an iterator or some object from which can be produced in iterator using the iter function. So most commonly a sequence like a list or a tuple or occasionally a map like a dictionary. And then of course at the end of this header line you have a colon denoting the end of the header followed by a body of statements indented underneath. What happens in the for-in is that the body is executed and in each run of the body the target is assigned each successive value of the iterable. So here for example 4x in with an iterable, which is a list with the values 8, 3 and a string moo. And then in the body we print x. Well this is a loop that will iterate three times and in the first run x will have the value 8 and the second it will have the value 3 and then in the third it will have the value of the string moo. So as you can see the for-in loop is a really convenient way of iterating over every item in some collection. Certainly more convenient than using a regular while loop and I would say a for-in loops are actually used the vast majority of the time in Python code while loops are only used in the minority of circumstances because the vast majority of times when we want to loop over something it's some collection where we want to go through all the values. Now if there's one little thing about a for-in loop it's that the variable the target which we are assigning to in each iteration that is just the variable of the local scope. So if you're in a function and you have a for-in loop that assigns to a variable x well that variable x actually just exists in the whole scope of the function so whatever last value it was assigned in the last iteration of the loop it'll still have that value after the loop. And this is just a good thing to know because if you happen to use a variable name earlier in the function which you then use as the target of a for-in loop well if you don't understand that it's actually the very same variable then you might be screwing something up and not understand what's going wrong. What Python calls a list comprehension is a convenient construct for creating a list from some existing list or some other kind of iterable type. So say we have this list of four items which we assigned to the variable or ridge as an original and then from that list we want to create a new list where all the items are the same except they have double the value. Well assuming we don't want to actually modify the existing list the way we do this is create a new list starting out as an empty list then we would iterate using a for-in loop all the items in the original list and in each iteration we would append to the new list that value times 2 and we would end up with a list that reads 10, 6, 4, 20. Well this is something Python programmers want to do commonly enough that it was decided they need a special construct to make it more compact to express because as we see here it takes up a good three lines first we're creating the new list assigning it to a variable and then the body list we're appending the values with a list comprehension we can do so in a single expression which returns the new list and what the syntax for this looks like is a list literal except instead of a list of values inside the square brackets you have first an output expression then the reserved word 4 and then a target and then the reserved word in just like in a for-in loop there's four target in what happens in the list comprehension is that implicitly there's some new list being created and we're iterating over the input just like in a for-in loop and assigning this value to the target in each iteration and then the values that get added to the list are the values of the output expression so here for example again we have our original list with four items and then from that we create our new list using a list comprehension each item of the input is being assigned to the variable x and then the output expression is x times 2 so when the list is iterated through 5, 3, 2, 10 first 5 is assigned to x and then the output expression is evaluated x times 2, 5 times 2 is 10 that's appended to the list and then we go on to the second iteration which is the value 3 assigned to x 3 times 2 is 6 6 appended to the list and so forth we go through the list in that manner so really it's just a single expression which does all the same work as what we saw with the regular for-in loop it's just this time we don't have to explicitly invoke a pen that's sort of done implicitly however there is one real subtlety of the list comprehension and that is that the target of the assignment is a variable which is local to the list comprehension itself so what gets assigned to x and the list comprehension has no bearing on a variable x outside the list comprehension and if we happen to have some variable also called x outside the list comprehension it has no bearing on the x in the output expression that's exclusively the x which is the target in the list comprehension so if we modify our example where instead of assigning it to a variable named the ridge we assign it to a variable named x we can then use that variable as the input expression but then the target variable x is totally independent of that there's really no relation here between the x outside the list comprehension and which we're using in the input expression that's totally separate from the x which is the target and which is used in the output expression so after the list comprehension is evaluated the x outside the list comprehension still has the same value it did before it still has the same list it doesn't have as you might expect the value 10 now there actually is a little bit more to list comprehension this is the simplest form you can actually make them a bit more complicated we won't discuss that stuff here so look in the supplementary notes for many kinds of collections it makes sense to convert them into some other kind of collection like for example if you have some tuple if you want a list of all the same values as that tuple you can create one simply by passing the tuple to the list constructor and that gets you a list with all the same values likewise you can get a tuple with all the same values as a list by passing the list to the tuple again be clear we're not actually identifying the existing objects we're just creating new ones based off of those objects so when I pass a range to the list constructor what I get is a new list consisting of all of those same values note though that not all types can be converted into others this way for example I can't go the other way here and convert my list into a range by passing the list to the range constructor that simply doesn't work and there's some cases of conversions where they do work but you have to be really clear what they do like for example here if we create an iterator from this list with the values 1, 2 and 3 and then we invoke next on that iterator well we've partially consumed the iterator and so if we then create a list from that iterator what we get is a list with just the remaining values not the values which have already been iterated through the very last thing we'll discuss here about collections is that when it comes to dictionaries Python has a requirement that the keys can't just be any object they must be immutable however if that object itself is composed of other items as say a tuple is a tuple is an immutable collection well those items themselves also must be immutable so it must be immutable all the way down so here we see this first example where we're creating a dictionary with one item where the key is itself a tuple but that tuple only contains numbers and numbers are immutable kinds of objects so this is okay this is a valid kind of dictionary key in the second example the dictionary key is a list an empty list but that's not okay because a list is immutable and then in the third example the key is again a tuple but one of the items of that tuple is itself a list and lists are immutable so this key isn't entirely immutable even though the tuple itself is immutable the tuple can't be modified to point to some other list or to some other object at that index but the object referenced by the tuple is not a valid dictionary key and just to drive home that a key has to really be immutable all the way down here we're trying to create a dictionary where the key is a tuple but that tuple contains a tuple which itself contains a list and lists are immutable so this tuple is not a valid dictionary key the reason python dictionaries have this requirement for their keys has to do with how python performs a lookup on a dictionary key the way the lookup is performed is for changing values if a key could change python wouldn't be able to locate its position in memory