############################################################
# subtypes
############################################################


# Back in Lecture 1, we explained that every object in Python has a
# type, and some fundamental built-in types are: int, float, bool, str.
# It should make sense that these are all distinct types. E.g., while
# ints and floats both represent numeric values, they have very
# different internal representations, with implications for numeric
# precision. Thus, different type obects (int and float) are needed to
# make that distinction, and it's useful to be able to inspect an
# object's type.


print()
print(isinstance(1, int))
print(isinstance(1, float))
print(isinstance(1.0, int))
print(isinstance(1.0, float))


# Similarly, ints and bools are conceptually distinct, so the following
# isinstance() calls should return identical results as above.


# print()
# print(isinstance(1, int))
# print(isinstance(1, bool))
# print(isinstance(True, int))
# print(isinstance(True, bool))


# Huh? The value True is both a bool AND an int? Indeed, in Python, the
# values True and False are actually identical to 1 and 0. So,
# interestingly, we can actually do arithmetic on bool values!


# print()
# print(True == 1)
# print(False == 0)
# print(True == 2)
# print(True + True)
# print((True + True) ** 2)
# print(sum([x > 10 for x in range(1, 20, 2)]))


# In that last example, we're performing a sum() over a list of boolean
# values: the list comprehension elements are of the form x > 10, which
# evaluates to either True or False. We've actually already snuck in
# boolean arithmetic in this manner before. In Lecture 15, we had code
# similar to the following to count primes:


def is_prime(num):
    if num == 1:
        return False
    if num == 2:
        return True
    for x in range(2, num // 2 + 1):
        if num % x == 0:
            return False
    return True


# print()
# print(sum([is_prime(x) for x in range(100)]))


# How might this work behind the scenes? We know that Python pre-loads
# objects representing the built-in types, so there should be type
# objects for int and bool. For bool objects to be interpreted as ints,
# though, there should be some connection between the int and bool
# types. How would you draw that in an environment diagram? As a hint,
# consider the following call to the built-in function issubclass(),
# which is the "type"-based counterpart to isinstance().


# print()
# print(issubclass(bool, int))


############################################################
# custom subtypes
############################################################


# Knowing that Python supports types being subclasses of other types,
# can we specify that in our custom types? The following demonstrates
# how on two simple classes. As you go, draw out the environment diagram
# on your own.

# Recall that a class definition creates a `type` object that simply
# stores names to other objects. Thus, Rock and Pebble are names in the
# global frames pointing to such objects. Rock's object contains a
# single name `alive` pointing to a bool object False, and Pebble's
# object contains `round` pointing to True.


class Rock:

    alive = False


class Pebble(Rock):

    round = True


# Note, however, that the Pebble class definition takes in Rock as a
# "parameter". This Pebble-Rock relationship is exactly analogous to the
# bool-int relationship. That is, Pebble is a subclass of Rock. Draw
# this relationship in your diagram in a way consistent with how you
# drew it for bool-int.


# print()
# print(issubclass(Pebble, Rock))
# print(issubclass(Rock, Pebble))


# What are the consequences of this subclassing? In the following
# print()s, we can access Rock.alive and Pebble.round as expected. We
# will also encounter a NameError when accessing Rock.round, as
# expected.

# However, Pebble.alive will evaluate to False, even though Pebble
# contains no name `alive`. What's happening is that when the Pebble
# type object determines it does not contain the name `alive`, it
# follows its connection to the Rock type object and performs this
# lookup there.


# print()
# print(Rock.alive)
# print(Rock.round)  # this line will not run, comment it out
# print(Pebble.alive)
# print(Pebble.round)


# This pattern of looking up names in another "scope" is reminiscent of
# what happens in a function's call frame when we try evaluate a name
# that has not been explicitly defined by that function. In all the code
# we've seen in this class, Python simply looks for that name in the
# global frame.

# It is important to understand that type objects are NOT frames -- they
# live on the heap as objects, whereas the stack is only for function
# call frames. However, the notion of looking in another "box of names"
# carries over. For types, this happens in the context of subclasses,
# where we have a clear notion of a "superclass" in which we should
# continue to look for a name.


########################################


# Now consider the following, where we've added an __init__() method for
# Rock. Then we create an instance of Rock, i.e., an object of type
# Rock, named `valka` in the global frame, and a Pebble instance named
# `hiccup`. Even though we never specified an __init__() for Pebble,
# hiccup still has a valid `color` attribute.

# Using the same principles as discussed above, you should be able to
# explain how hiccup gets its `color` using an environment diagram. Try
# this on your own, and we'll discuss at the start of lecture.


class Rock:

    alive = False

    def __init__(self):
        self.color = "blue"


class Pebble(Rock):

    round = True


valka = Rock()
hiccup = Pebble()

# print()
# print(valka.color)
# print(hiccup.color)
