PCEP-30-01

I made these notes from going through the free OpenEDG Edube PCEP course, enjoy!

each computer has an IL (Instruction List): the only commands a computer can perform (very basic)
	- executed in machine language for computers (ones and zeros)
	- each computer architecture has a different language (x86, RISC, MIPS, etc...)
	* computer architecture = ISA (Instruction Set Architecture)
components of language:
	- alphabet: symbols to build words
		* IL = alphabet of computer (simplest component of computer language)
	- lexis: dictionary - set of all words
	- syntax: rules that govern the structure of a language 
	- semantics: rules that govern the meaning of a language
programming languages:
	- created for humans to write programs for computers to execute
	- humans cannot write in machine code, too hard
	- high-level programming languages are in between natural language and machine language
compilation & interpretation:
	- a program is run in machine language, must convert from human readable to machine readable
	- 2 ways of conversion:
		1) compilation: source code is translated once, into machine code, creates new executable file that can be rn anytime
			- fast execution
			- compiled for specific architecture
			- user doesn't need compiler
			- translated code stored as machine language
			- hard to decompile and make source code from it. Good for closed source apps
			- compiling takes a long time, can't run program right away
		2) interpretation: source code is translated everytime it's run. user need interpreter to execute it.
			- interpreter checks that all lines are correct before running (error if not)
			- code is stored as source
			- can run on any computer architecture as long as user has interpreter
			- not as fast as compiled code
			- interpreted languages = scripting languages, programs are scripts
			* Python is interpreted, to run, need Python interpreter
ruby & perl are direct competitors
Python is not used for low level programming or mobile applications
Types of Python:
	- Python 2 & Python 3 are not compatible
	- PSF (Python Software Foundation) implementation of Python is often referred to as CPython (written in "C" language)
		- run by creator Guido Van Rossum
	- Cython automatically translates Python (clean and clear, but not too swift) into "C" code (complicated, but agile).
	- Jython = same as Cython but for Java
		- no Python 3 Jython yet
	- PyPy: tool for Python development written in RPython 
		- converted to C and stored as executable
		- easier to check new features of Python
		- similar to CPython
	- RPython: subset of Python - Restricted
	* This course focuses on CPython
* Debugger: program able to launch your code step-by-step, line-by-line
	- for troubleshooting
IDLE (Integrated Development and Learning Environment)

Traceback: path that the code traverses through different parts of the program

			
Module 2
Data types, variables, basic input-output operations, basic operators


function: 
	- cause some effect
	- evaluate a value
	* some functions do one or both of the above
	- functions come from 3 places:
		1) python itself: Python 3.8 comes with 69 built-in functions (https://docs.python.org/3/library/functions.html)
		2) python modules: some built in, some added
		3) write functions yourself: 
		4) lambda functions - connected to classes (omitted for now)
	- 3 parts of a function:
		- arguments: values passed to function (some functions require 0 arguments)
			* enclosed in parentheses (even if no arguments)
				- ex: print("hello") & doThis() are both functions
		- effect: what function does to arguments
		- result: what function returns
	
function invocation/call:
	- using a function
	- steps that interpreter uses to invoke a function:
		1) check if function name is legal (defined)
		2) checks if function's requirements for number of arguments is met
		3) takes your arguments, passes to function
		4) function executes its code, causes effect, evaluates result
		5) returns to your code and resumes execution

* Python requires that there cannot be more than one instruction in a line
	- Python makes one exception to this rule - it allows one instruction to spread across more than one line

Print Function:
	- ex: print("Hello, World!")
	- print = function name
	- print() begins its output from a new line each time it starts its execution (can be changed)
	- instructions are executed in the same order in which they have been placed in the source file (some exceptions)
	- new/empty lines:
		- print() --> creates empty line
		- \n --> inside double quotes in print function makes rest of text go to next line
			- ex: print("The itsy bitsy spider\nclimbed up the waterspout.")
	- escape characters:
		- \ backslash allows to execute something where it normally isn't allowed (like in strings)
			- the letter after the backslash indicates what to do (ex: \n --> next/new line)
		- if putting backslash as part of string, need to double it \\
		- examples:
			- \n -- new line
			- \t -- tab
	- multi-argument print:
    	1) a print() function invoked with more than one argument outputs them all on one line.
    	2) the print() function puts a space between the outputted arguments by default.
		* default all arguments are separated by space
		* using the positional way of passing arguments into the function
			- second argument will be outputted after first
		- ex: print("hello", "world!")
	- positional arguments:
		- normal arguments that depend on position
		- ex: print("hello", "world!")
			- will print out from first to last
	- keyword arguments:
		- change behaviour of function (not position based)
		* all keyword arguments must be put after the last positional argument
		- 3 elements:
			1) keyword
			2) equal sign
			3) value assigned to argument
		- The print() function has 2 keyword arguments 
			1) end
				- putting a space makes 2 print() functions appear on same line
				- ex: print("hello", end=" ")
					  print (blah)
					  result: hello blah
			2) sep
				- separate all arguments with a character/anything
				* all arguments are separated with a space by default
				- ex: print("hello", "you", sep="-")
					  result: hello-you
		- Both keyword arguments may be mixed in one invocation
	- multi-line printing:
		- use tripple quotes to open and close """
		ex:
			print(
			"""
			This is a multi-line
			printing example!
			""")

2.2
Literals

- literal: specific use-cases of data types
	- ex: 123 is a literal (of integer data-type)  
	- ex: c is not a literal (doesn't belong to any data-type) 
	- ex: "c" is a literal (of string data-type)
	- a string "2" and an integer 2 are stored differently in the computer but appear the same to us and are both literals
- types / data-types / representation:
	- numeric literals can be one of 2 types:
		1) integer: whole numbers (including negatives)
			- ex: 100
		2) floating-point / float: (non-empty fractions/decimal numbers)
			- ex: 2.5
			* can omit zero when it is the only digit in front of or after the decimal point
				- ex: 0.4 & .4 are acceptable. 4.0 & 4. are also acceptable
				- any number with a point/dot = floating point
			- scientific notation:
				- 300000000 = 3 x 10^8 = 3E8
				- python: 
					- ex: 3E8 OR 3e8 are acceptable notations 
					- ex: 3.15E-34 (very small number)
			- Python always chooses the more economical form of the number's presentation
				- ex: print(0.0000000000000000000001) --> returns: 1e-22
		* can only use numbers, sign & underscores for separating numbers
			- 11111111 OR 11_111_111 are acceptable
			- underscores only for readablility
				- only from Python 3.6
			- negative numbers: -22
			- positive numbers: 22 OR +22 
		- If an integer number is preceded by an 0O or 0o prefix (zero-o), it will be treated as an octal value
			- base 8 (0-7)
			- ex: 0o123 is an octal number with a (decimal) value equal to 83
			- ex: print(0o123)
		- If an integer number is preceded by 0x or 0X (zero-x), it will be treated as a hexadecimal value
			- base 16 (0-F)
			- ex: 0x123 is a hexadecimal number with a (decimal) value equal to 291
			- ex: print(0x123)
	- Strings:
		- quotes: "double" OR 'single' make anything into a string 
			- tells interpreter to not regard it as code, but as string data
		- 2 methods to print quotes inside a string:
			1) use backslashes
				- ex: print("hello \"Mike\"")
			2) use both quotation marks types
				- ex: print("hello'Mike'") or print('hello "Mike"')
		- "string"*2 will produce "stringstring"
	- Booleans:
		- True & False 
			- *Case Sensitive!
		- result of comparison
		- George Boole (1815-1864), author of The Laws of Thought, contains definition of Boolean algebra (1 & 0 & True & False)
		- print(True > False) --> result: True
		- print(True < False) --> result: False
			- True = 1, False = 0
	- None:
		- NoneType is for None literal
		- absense of a value
		

2.3
Operators & Expressions

Expressions = Data + Operator
	- print(2+2)
Operators:
+, -, *, /, // (), % (remainder), ** (exponent)
- if one or more arguments are floats, result is float
- if all arguments are integers, result is an integer
	- exception: The result produced by the division operator (/) is always a float
		- ex: print(6 / 3) --> result = 2.0
		- // double slash makes integer division possible!
			- integer division = floor division
			- ex: print(6 // 3) --> result = 2
			- works the same as the other operators, if there is a float, it will return a float as well
		- complex examples:
			- print(6 / 4) = 1.5		exact decimal value (float)
			- print (6. / 4) = 1.5		exact decimal value (float)
			- print (6 // 4) = 1		round down, no decimal (integer)
			- print (6. // 4) = 1.0		round down, with decimal (float)
			* rounding always goes to the lesser integer
				- example with negative numbers
					- print(-6 // 4) = -2
					- print(6. // -4) = -2.0
					* result is -2 & -2.0 because those are the lesser integers, rounding down!
					* if it were positive, it would have been 1 & 1.0
		* floor division = integer division (//)
- % modulo (remainder left after the integer/floor division)
	- print(14 % 4) = 2
	- explanation:
    	14 // 4 gives 3 → this is the integer quotient;
    	3 * 4 gives 12 → as a result of quotient and divisor multiplication;
    	14 - 12 gives 2 → this is the remainder.
	- print(12 % 4.5) = 3.0 (float)
* division by zero doesn't work
* 5 % 24 = 5 --> explanation: 24 can't go into 5 at all, therefore no remainder. Just ignores it and outputs the 5.

- In subtracting applications, the minus operator expects two arguments: 
	1) left (a minuend in arithmetical terms) 
	2) right (a subtrahend).

Unary vs Binary Operators:
- Binary: +, -, *, /
	- when performing arithmetic operations
	- ex: 4+5, 8-1, etc...
- Unary: +, -
	- when used for signing numbers
	- ex: -4, +5.5, -20.2, etc...

Hierarchy of Priorities:
	- determines which operations are done first (like BEDMAS, but Python has different standard)
	* subexpressions in parentheses are always calculated first (also used for readability)

	1) 	~, +, - 		unary
	2) 	** 	
	3) 	*, /, //, % 	
	4) 	+, - 			binary
	5) 	<<, >> 	
	6) 	<, <=, >, >= 	
	7) 	==, != 	
	8) 	& 	
	9) 	| 	
	10 	=, +=, -=, *=, /=, %=, &=, ^=, |=, >>=, <<= 	

Bindings of Operators:
	- binding of the operator determines the order of computations performed by some operators with equal priority
	- most operators have left-sided binding (perform operations left to right if multiple operators of same priority)
	- ex: print(9 % 6 % 2) = 1
		- 2 ways of performing:
    		1) from left to right: first 9 % 6 gives 3, and then 3 % 2 gives 1;
    		2) from right to left: first 6 % 2 gives 0, and then 9 % 0 causes a fatal error.
	- ** exponentiation = right-sided (right to left) binding
		- ex: print(2 ** 2 ** 3) = 256
		- 2 ways of performing:
    		1) from left to right: 2 ** 2 → 4; 4 ** 3 → 64
    		2) from right to left: 2 ** 3 → 8; 2 ** 8 → 256

		

2.4
Variables

every variable must have:
	1) name
    	- the name of the variable must be composed of upper-case or lower-case letters, digits, and the character _ (underscore)
    	- the name of the variable must begin with a letter;
    	- the underscore character is a letter;
    	- characters are case-sensitive: upper and lower-case letters are treated as different;
		- the name of the variable must not be any of Python's reserved words 
		* no spaces, cannot begin with number
		* can use foreign alphabets as well
		* same restrictions apply to function naming
	2) value

PEP 8 Style Guide:
- suggestions for clean and uniform code
    - variable names should be lowercase, with words separated by underscores to improve readability (e.g., var, my_variable)
    - function names follow the same convention as variable names (e.g., fun, my_function)
    - it's also possible to use mixed case (e.g., myVariable), but only in contexts where that's already the prevailing style, to retain backwards compatibility with the adopted convention.

Reserved Keywords:
- not for use in naming variables/functions
- can change to upper/lowercase and use that
- their meanings are predefined
['False', 'None', 'True', 'and', 'as', 'assert', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', 'if', 'import', 
    'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'while', 'with', 'yield']

Creating Variables:
- must name & assign a value
- ex: var = 1
- to assign a new value, do the same thing with a different value (var = 2)
	- '=' is assignment, not 'equal to'

Combine Variables & Strings:
var = "3.8.5"
print("Python version: " + var)

Compound Assignment Operators OR Shortcut Operators:
- x *= 2 	(means x = x*2)
- x += 1 	(means x = x+1)
- x /= 2 	(means x = x/2)
- x %= 10	(means x = x%2)
- x **= 2	(means x = x**2)

round() function:
- round the outputted result to the number of decimal places specified in the parentheses
- ex: print(miles, "miles is", round(miles_to_kilometers, 2), "kilometers")
	- rounds the variable 'miles_to_kilometers' to 2 decimal places

* Python is a dynamically-typed language
	- don't declare variables, just assign values to them

Trick Question:
a = '1'
b = "1"
print(a + b)
= 11 (because both are strings! just print side by side 1 and 1)



2.5
Comments

- comments are omitted at runtime
	1) # this is a single line comment (hashtag)
	2) 	"""
		this is a
		multi line 
		comment (tripple quotes)
		"""
- responsible developers describe each important piece of code
- self-commenting: naming things with obvious unambiguous names
- shortcut to make #: CTRL + / 



2.6
Input Function & String Operators

- input() takes input data from the user 
	* input data is always a string
		- cannot be used for arithmetic
	- usually invoked without arguments
	- switches the console to input mode
- usually uses keyboard, can use voice/image
- deaf program: program that doesn't read/process user data
- ex1: 	
	print("Tell me anything...")
	anything = input()
	print("Hmm...", anything, "... Really?")
- ex2:
	anything = input("Tell me anything...")
	print("Hmm...", anything, "...Really?")
	- behaves the same as ex1 but more concise

Inputing Numbers:
	- functions: int() & float() 
    	- the int() function takes one argument (e.g., a string: int(string)) and tries to convert it into an integer; if it fails, 
    	    the whole program will fail too (there is a workaround for this situation, but we'll show you this a little later);
    	- the float() function takes one argument (e.g., a string: float(string)) and tries to convert it into a float (same)
	- ex: x = float(input("Enter a number: "))

concatenation operator:
	- the + sign between 2 strings concatenates them
	- ex: print("this" + " " + "is" + " " + "good")

replication operator:
	- the * sign between a string and a number replicates the string as many times as the given number 
	- ex: print("hello" * 2) = hellohello
	* numbers less that or equal to 0 produce an empty string

convert a number into a string:
	- function: str()
	- ex: str(300) --> turns int 300 into string
	- ex: print("Hypotenuse length is " + str((leg_a**2 + leg_b**2) ** .5))
		- eliminate need for separating with commas, can just concatenate

Module 2 Lab:
hour = int(input("Starting time (hours): "))
mins = int(input("Starting time (minutes): "))
dura = int(input("Event duration (minutes): "))

# Write your code here.
end_hour = ((((hour*60)+mins)+dura)//60)%24
end_mins = (((hour*60)+mins)+dura)%60
print("Start Time = ", hour, ":", mins, "\nDuration = ", dura, "\nEnd Time = ", end_hour, ":", end_mins)

#have starting hour & minute
#have duration minutes
#need ending hour & minute

#starting hour & minutes + duration minutes = ending hour & minutes
#must convert hour to minutes (*) and then divide (/) by 60 to get hours and modulo (%) by 60 to get minutes

#if divisible by 24, reset hour to 0



Module 3
Boolean Values, Conditional Execution, Loops, Lists and List Processing, Logical and Bitwise Operations



3.1
Operators

= 	--> assignment operator
== 	--> equal to operator
	- binary operator, left-sided binding, checks if 2 arguments are equal
	- ex1: 2 == 2 	= True
	- ex2: 2 == 2.0 = True
		* floats and integers can be evaluated to eachother
!= 	--> not equal to
> 	--> greater than 
< 	--> less than
>=	--> greater than or equal to
<=	--> less than or equal to

*** Can store these answers (True/False) in a variable OR use them as a conditional
*** These operators always return either True OR False (Boolean)
*** Refer to hierarchy of priorities above to see all the operator priorities

LAB:
x = int(input("please enter a number: "))
print(x >= 100)
*** prints False if x <= 100 and prints True if x >= 100
*** NO IF STATEMENTS!

Conditional Instructions:

- if statement requirements:
    the if keyword;
    one or more white spaces;
    an expression whose value will be interpreted solely in terms of True (1) and False (0);
    a colon followed by a newline;
    an indented instruction or set of instructions; 
	the indentation may be achieved in two ways 
		1) by inserting a particular number of spaces (the recommendation is to use four spaces of indentation), 
		2) by using the tab character; 
		note: if there is more than one instruction in the indented part, the indentation should be the same in all lines; 
		Python 3 does not allow mixing spaces and tabs for indentation.
	* if & else are keywords
	- ex: 
		if x > y:
			print("x is greater than y!")
		print("Goodbye!")
		*** the indented line is conditional upon x > y, however Goodbye! will execute regardless because not indented
	- ex2:
		if x > y:
			print("x is greater than y!")
		else:
			print("x is not greater than y!")
		print("Goodbye!")
		*** else statement
	- can have nested if-else statements
	- elif is used to check more than just one condition (stops when one is true)
		- cascade: assemble subsequent if-elif-else statements
		- rules:
    		you mustn't use else without a preceding if;
    		else is always the last branch of the cascade, regardless of whether you've used elif or not;
    		else is an optional part of the cascade, and may be omitted;
    		if there is an else branch in the cascade, only one of all the branches is executed;
    		if there is no else branch, it's possible that none of the available branches is executed.
	*** if any of the if-elif-else branches contains just one instruction, you may code it in a more comprehensive form
		- ex: 
			if number1 > number2: larger_number = number1
			else: larger_number = number2
	- min() Python built-in function:
	- max() Python built-in function:
		- ex:
			largest_number = max(number1, number2, number3)
*** can create multiple variables at once!
	ex: x, y, z = 5, 10, 8
*** watchout for tricks
	- x=1 != x="1"
		- one is an int, one is a string!



3.2
Loops

While Loops:
- ex: while 2 == 2: do print("2 still equals 2!")
*** 'if' performs its statements only once; 'while' repeats the execution as long as the condition evaluates to True
- same rules for indentation with while loops
- Rules:
	executing more than one statement inside one while requires indentation of all the instructions;
	an instruction or set of instructions executed inside the while loop is called the loop's body;
	if condition is False (0) to start with, the body is not executed even once;
	the body should be able to change the condition's value, otherwise loop is infinite/endless.
		* example of infinite loop --> while True: print("I'm stuck inside a loop.")
- these are equivalent (shortcuts): 
	- while number != 0: ||| while number:
	- if number % 2 == 1: ||| if number % 2:

For Loops:
- ex: 
	for i in range(100):
	    # do_something()
	    pass
- Rules:
    there's no condition after 'for' keyword; conditions are checked internally;
    any variable after the for keyword is the control variable of the loop; it counts the loop's turns;
    the 'in' keyword introduces a syntax element describing the range of possible values being assigned to the control variable;
    the range() function is generates all the desired values of the control variable; 
	in our example, the function will create subsequent values from 0 to 99; 
	note: in this case, the range() function starts its job from 0 and finishes it one step before the value of its argument;
    note the pass keyword inside the loop body - it does nothing at all; it's an empty instruction - we put it here because the for loop's syntax demands at least one instruction inside the body 
        (by the way - if, elif, else and while express the same thing)

- range() starts counting from 0 by default
* range(start, stop, step) --> this is what the arguments do
	- only using one argument specifies the stop
- range() function invocation may be equipped with two arguments
	- ex: for i in range(2, 8): 
		- starts count at 2 and ends at 7
- range() function may also accept three arguments
	- ex: for i in range(2, 8, 3):
		- third argument = increment (default = 1)
* if range() is empty, no loop
	- also if range(1, 1), no loop
	- also if range(2, 1), no loop

*** IMPORTANT Example:
user_word = input("Enter your word: ")
user_word = user_word.upper()
for letter in user_word:	-------------------> this will go through each letter in the word!
    if letter == "A": continue
    elif letter == "E": continue
    elif letter == "I": continue
    elif letter == "O": continue
    elif letter == "U": continue
    else:
        print(letter)

- Syntactic Candy: 
	- break: keyword used to stop and exit a loop
		- ex: 
			print("The break instruction:")
			for i in range(1, 6):
			    if i == 3:
			        break
			    print("Inside the loop.", i)
			print("Outside the loop.")
	- continue: keyword to start back at the top of a loop
		- ex: 
			print("\nThe continue instruction:")
			for i in range(1, 6):
			    if i == 3:
			        continue
			    print("Inside the loop.", i)
			print("Outside the loop.")
	* these keywords don't add anything to the actual function of Python, just make it easier to use
* 'for' & 'while' loops can have an 'else' branch, like 'if' statements
	* The loop's else branch is ALWAYS EXECUTED ONCE, regardless of whether the loop has entered its body or not.



3.3
Logic & Bit Operators

Logical Operators:
	not = negation (unary operator w high priority)
	and = conjunction
	or = disjunction (binary operator w lower priority than 'and')
	* logical operators do not penetrate into the bit level of its argument (unlike Bitwise Operators). 
		- They're only interested in the final integer value.

pairwise equivalence:
	- var != 0 		and		not(var == 0) 	are pairwise equivalent
	- mean the same thing, written differently

De Morgan's Laws:
not (p and q) == (not p) or (not q)
not (p or q) == (not p) and (not q)

Bitwise Operators:
	& (bitwise conjunction AND)
	| (bitwise disjunction OR)
	~ (bitwise negation NOT)
	^ (bitwise exclusive or XOR)
	*** arguments for these operators MUST be integers
	*** Bitwise Operators deal with every bit separately (one operation per bit from argument)
	- shortcuts:
		x = x & y -->	x &= y
		x = x | y -->	x |= y
		x = x ^ y -->	x ^= y
	- Reset your bit:
	- ex:
		flag_register = 00000000000000000000000000001000
		the_mask = 8
		flag_register &= ~the_mask (invert the bit with value 8 (3rd bit cause 2^3=8))


*** look more into the difference between Logical and Bitwise operators ***


examples:
    x = 15, which is 0000 1111 in binary,
    y = 16, which is 0001 0000 in binary.

    & does a bitwise and, e.g., x & y = 0, which is 0000 0000 in binary,
    | does a bitwise or, e.g., x | y = 31, which is 0001 1111 in binary,
    ˜ does a bitwise not, e.g., ˜ x = 240*, which is 1111 0000 in binary,
    ^ does a bitwise xor, e.g., x ^ y = 31, which is 0001 1111 in binary,
    >> does a bitwise right shift, e.g., y >> 1 = 8, which is 0000 1000 in binary,
    << does a bitwise left shift, e.g., y << 3 = , which is 1000 0000 in binary,


??? 

Set your bit:
x | 1 = 1
x | 0 = x
flag_register |= the_mask

Check the state of your bit:
x & 1 = x
x & 0 = 0
if flag_register & the_mask:
    # My bit is set.
else:
    # My bit is reset.

Negate your bit:
x ^ 1 = ~x
x ^ 0 = x
flag_register ^= the_mask

???

digraphs: << and >>
- shift binary number too left/right
- left shift = *2
- right shift = /2
- ex: 
	- value << bits OR value >> bits
		- value = integer AND bits = size of shift



3.4
Lists

scalars: single value variable (all previous variables discussed)
multi-value variable: variable with multiple elements/values (ex: lists)
	- each element/value is a scalar

list example: numbers = [10, 5, 7, 2, 1]
	- square brackets
	- length = 5
	- positions: 0, 1, 2, 3, 4
		* position always starts with 0

my_list = [] --> create empty list

assign new value to element:
- index: position of an element/value
- indexing: selecting an element/value based on position
ex1: numbers[0] = 111
ex2: numbers[1] = numbers[4]
	- copy 5th element to 2nd position

elements in lists may have different data-types
	- can have lists inside lists (nested lists)

print(numbers[0]) 	--> prints value at 1st index
print(numbers) 		--> prints all values
print(len(numbers))	--> prints number of values in list
del numbers[1]		--> delete a value at index 1
print(numbers[-1])	--> prints last element in list
print(numbers[-2])	--> prints second last element in list
* cannot assign value to index that doesn't exist
* cannot retrieve value of index that doesn't exist

Functions:
- create or retrieve data
- owned by whole code
- invocation example:
	- result = function(arg)

Methods:
- subset (type) of a function
- owned by data it works for
- can change state of data it works for (unlike functions)
	* used to add elements to an existing list
- invoked differently & acts differently than function
	- requires specified data to invoke
- invocation example:
	- name_of_data.name_of_method(arguments)
	- result = data.method(arg)

list.append(value) --> append value to end of list
list.insert(location, value) --> insert a value at specified index

These two are equivalent:
1) 	for i in my_list:
    	total += i
2) 	for i in range(len(my_list)):
    total += my_list[i]

Swapping Variables & Order:
- variable_1, variable_2 = variable_2, variable_1
- my_list[0], my_list[4] = my_list[4], my_list[0]
- within a for loop:
	for i in range(length // 2):
	    my_list[i], my_list[length - i - 1] = my_list[length - i - 1], my_list[i]

Nested Lists:
ex: my_list = [1, 'a', ["list", 64, [0, 1], False]]



3.5 
Sorting Lists

Bubble Sort Algorithm:
- sort by comparing first 2 numbers and swapping their places if out of order. Repeat with all other positions.
	- may have to go through list multiple times to sort everything
- list can be sorted by increasing or decreasing values

Sorting Methods:
- Python comes with in-built sorting methods, don't need to implement your own sorting algorithm
- my_list.sort()
	- sorts ascending
- my_list.reverse()
	- reverses any list order

- the name of an ordinary variable is the name of its content;
- the name of a list is the name of a memory location where the list is stored.
	list_1 = [1]
	list_2 = list_1
	list_1[0] = 2
	print(list_2)
		* --> prints 2 NOT 1!!!
		- lists store elements/values differently than ordinary variables
		- when list 2 = list 1, list 2 will update to list 1's values even if it's after the assignment!

- make lists store values same as variables:
	- Slice: make a brand new copy of a list, or parts of a list.
		- won't update when another is updated
	- list_2 = list_1[:]
		- [:] creates a new copy of ALL the values and assigns them to list_2
	- my_list[start:end]
		- [0:-1] --> can define index start and end to copy
		* -1 will only copy until second last digit 
			- end value [:4] copies until the number -1 (until index 3 in this case)
			- this example would only copy from the first value to the second last
			- omitting first value [:-1] means start from index 0
			- omitting last value [0:] means end at last value
	- able to delete slices:
		- ex: del my_list[1:3] --> delete from index 1 to 2
		- ex: del my_list[:] --> delete all values

Checking for elements in list:
	- 2 operators:
		- in		--> elem in my_list
		- not in	--> elem not in my_list
			- checks if "elem" is/isn't in the list --> returns True/False
	- ex:
		my_list = [0, 3, 12, 8, 2]
		print(5 in my_list)
		--> result: False



Tricky code (2 examples):

bets = [3, 7, 11, 42, 34, 49]
for number in bets:
	print(number)

	ANS --> 3, 7, 11, 42, 34, 49
	* number actually becomes bets


bets = [3, 7, 11, 42, 34, 49]
for number in range(len(bets)):
	print(number)

	ANS --> 0, 1, 2, 3, 4, 5
	* number becomes index numbers of bets


3.6 lab:
my_list = [1, 2, 4, 4, 1, 4, 2, 6, 2, 9]

for i in my_list:
    if i in my_list:
        del my_list[i]

print("The list with unique elements only:")
print(my_list)


Tricky Example:

list_1 = ["A", "B", "C"]
list_2 = list_1
list_3 = list_2

del list_1[0]
del list_2

print(list_3)

ANS --> 'B', 'C'
** this would be the answer even if list_2 & list_1 were deleted. The variables would remain with list_3 pointing to them, but list_1 and list_2 would no longer exist
** when deleting some values part of the list, all of them are affected, but when deleting a list, only the one specified goes



3.7
Multidimensional Lists

- AKA: matrix / matrices
- rows and columns (chess board for example - A1 to H8)
- List comprehension allows you to create new lists from existing ones in a concise and elegant way.
	- [expression for element in list if conditional]
	- above is equal to = 
		for element in list:
		    if conditional:
		        expression
	- real example:
		cubed = [num ** 3 for num in range(5)]
		print(cubed)  # outputs: [0, 1, 8, 27, 64]

- list comprehension: building large lists
	- ex: row = [WHITE_PAWN for i in range(8)]
		- creates row with 8 white pawns
	- ex: squares = [x ** 2 for x in range(10)]
		- ANS: (0, 1, 4, 9, 16, 25, 36, 49, 64, 81)

- long form:
	board = []
	for i in range(8):
	    row = [EMPTY for i in range(8)]
	    board.append(row)
- short form (nested):
	board = [[EMPTY for i in range(8)] for j in range(8)]

assigning values to matrices:
- board[4][2] = KNIGHT
	- [4][2] = C4
	- x coordinate (row) & y coordinate (column)

- for loops for matrices:
for day in temps:
    for temp in day:
        if temp > highest:
            highest = temp
itterate through rows, then through columns. Need 2 for loops

3D lists:
- 3 buildings, 15 floors, 20 rooms (which rooms are occupied?).
- ex: rooms = [[[False for r in range(20)] for f in range(15)] for t in range(3)]
- ex: rooms[1][9][13] = True --> books a room in the second building on the 10th, room 14

Nested Lists:

# A four-column/four-row table ‒ a two dimensional array (4x4)

table = [[":(", ":)", ":(", ":)"],
         [":)", ":(", ":)", ":)"],
         [":(", ":)", ":)", ":("],
         [":)", ":)", ":)", ":("]]

print(table)
print(table[0][0])  # outputs: ':('
print(table[0][3])  # outputs: ':)'


# Cube - a three-dimensional array (3x3x3)

cube = [[[':(', 'x', 'x'],
         [':)', 'x', 'x'],
         [':(', 'x', 'x']],

        [[':)', 'x', 'x'],
         [':(', 'x', 'x'],
         [':)', 'x', 'x']],

        [[':(', 'x', 'x'],
         [':)', 'x', 'x'],
         [':)', 'x', 'x']]]

print(cube)
print(cube[0][0][0])  # outputs: ':('
print(cube[2][2][0])  # outputs: ':)'




Module 4

4.1
Functions

Rules:
1) *** if a particular fragment of the code begins to appear in more than one place, consider the possibility of isolating it in the form of a function. ***
2) *** if a piece of code becomes so large that reading and understating it may cause a problem, consider dividing it into separate, smaller problems, and implement each of them in the form of a separate function. ***
	- AKA Decomposition
3) *** if you're going to divide the work among multiple programmers, decompose the problem to allow the product to be implemented as a set of separately written functions packed together in different modules. ***

Writing Functions:
- creating a function with no parameters:
	def my_function():
		# function body
- rules:
	- naming of functions has same rules as variables
	- must have one instruction at least
	- everything indented is part of function body
	- must define the function before invoking it
	- function must not have same name as a variable
- invocation:
	- my_function()
	- my_function(parameter)
- creating a function with parameters:
	def my_function(parameter):
		# fuction body
	* parameters are what is passed to function
		- parameters are variables with special qualities:
			1) parameters exist only inside functions they've been defined in
			2) assigning a value to the parameter is done at the time of the function's invocation
	- rules for parameters:
		- must provide same number of arguments as there are parameters in the function when invoking it
			- ex: 
				def address(street, number, city): # defining the function
					print(city, number, street) # function body
				city = input("please input your city: ") # user input
				number = input("please input your house number: ") # user input
				street = input("please input your street: ") # user input
				address(street, number, city) # invoking the function
				*** note: It's legal, and possible, to have a variable named the same as a function's parameter (as above).
					- NOTE: this is called *shadowing*
					- although they are not connected and may have different names
	- Positional parameter passing
		- passing parameters in the order that they are listed in the function definition
			- ex:
				def numbers(first, second):
					print(first, second)
				numbers(1, 2)
	- Keyword argument passing
		- naming your variable the same as the parameters makes the order in which you pass them to the function unimportant
		- ex:
			def introduction(first_name, last_name):
			    print("Hello, my name is", first_name, last_name)
			
			introduction(first_name = "James", last_name = "Bond")
			introduction(last_name = "Skywalker", first_name = "Luke")
			* will output both as firstname lastname regardless of order of arguments passed to function
	- Mixing positional and keyword arguments
		- must put positional arguments before keyword arguments
	- Parameterized functions
		- can set default values for parameters for the ones that are optional
		- ex:
			def introduction(first_name, last_name="Smith"):
			* default last name = smith, but if something else is passed, will use that
				- if you want to call a function without passing any arguments, just put a default for every parameter
	*** remember positional arguments must come before keyword arguments, else error
	*** also cannot pass arguments after default parameter (if b=2 is a default parameter, and c comes after, cannot pass c)
		***** look into this more



4.3
Return Keyword

return uses:
	* both used inside a function 
	1) 'return' immediately stops function and goes back to executing code body
		- return isn't necessary, will happen anyway at end of function (implicit return)
	2) 'return x' immediately stops function execution & returns value 'x' as function's result 
		- can set a variable equal to the function, which will equal what it returns
			- if you don't save the return value in a variable, it is lost
		- ex: 
			def boring_function():
			    return 123
			x = boring_function()
			print("The boring_function has returned its result. It's:", x)
	
None Keyword
- not a value at all, cannot be in any expression (cause error)
- use cases:
	1) assign None to variable or return as function result
		* if no value is returned by a function, it implicitly returns None!
	2) compare with a variable to diagnose internal state (checking if no input was entered)


			
4.4
Scopes

- scope of name (variable) is part of code where name is recognized
	- variables defined in a function will not be in scope when outside the function
	- variable defined outside a function has a scope inside and outside function bodies (global)
	- redefining a variable inside a function will overwrite the value only for the function

Global Keyword
- extends variable's scope to include function bodies
- allows to modify outside variables inside function & reference variables created inside function from outside
- ex:
	global name 	#must first state global
	name = Jean		#then assign/change value

Tricky code:
def my_function(my_list_1):
    print("Print #1:", my_list_1)
    print("Print #2:", my_list_2)
    del my_list_1[0]  # Pay attention to this line.
    print("Print #3:", my_list_1)
    print("Print #4:", my_list_2)


my_list_2 = [2, 3]
my_function(my_list_2)
print("Print #5:", my_list_2)


output: 
Print #1: [2, 3]
Print #2: [2, 3]
Print #3: [3]
Print #4: [3]
Print #5: [3]

* changes my_list_2 even though only my_list_1 was changed
	- this is because we passed the variable my_list_2, as opposed to a non-list. 
		- non-lists are a scalars and only passes the value when given to a function, not the variable itself
		- thus changes apply to both list 1 and list 2



4.5
Creating Functions

\ will tell python to use the next line of code as if it were the same line
	- ex: 
	if height < 1.0 or height > 2.5 or \
    weight < 20 or weight > 200:
	- these two lines will be treated as one line of code by python

Recursion:
- when a function invokes itself
- invoking the function the code is in from within the function
- ex:
	def function():
		print("hello")
		function()
	* this will infinitely print hello
- can be used when changing a value that will end the loop



4.6
Tuples & Dictionaries

Sequence Types:
- data that can store more than one or less than one value (empty sequence)
- data which can be scanned by a for loop is a sequence
	- example: a list is a sequence 

Mutability:
1) mutable: data that can change/update during the execution of code	
	- lists are mutable sequence types
	* modifyin data in situ = updating mutable data
		- ex: list.append(1)
2) immutable: data that can't be modified/changed (can only read & use data as is)
	- tuples are immutable sequence types

Tuples:
- tuples have round brackets or no brackets
	- separate values by ',' -- if 1 value with no ',' will just be a single scalar value
- tuple vs list:
	- tuple_1 = (1, 2, 3, 4)
	- tuple_2 = 1., .5, .25, .125
	- tuple_empty = ()
	- one_element_tuple_1 = (1, )
	- one_element_tuple_2 = 1.,
	- list_1 = [1, 2, 3, 4]
- interacting with tuples:
	- same as lists except can't modify
	- ex: 
		print(my_tuple[0])
		print(my_tuple[-1])
		print(my_tuple[1:])
		print(my_tuple[:-2])
    - the len() function accepts tuples, and returns the number of elements contained inside;
    - the + operator can join tuples together (we've shown you this already)
    - the * operator can multiply tuples, just like lists;
    - the in and not in operators work in the same way as in lists.
	* can still swap values:
		t1, t2, t3 = t2, t3, t1

	
Dictionaries:
- dictionary = mutable data structure, not sequence type
- dictionary = a bunch of key-value pairs in curly brackets
	- ex: 
		dictionary = {"cat": "chat", "dog": "chien", "horse": "cheval"}
		phone_numbers = {'boss': 5551234567, 'Suzy': 22657854310}
		print(dictionary)
- rules:
    each key must be unique - it's not possible to have more than one key of the same value;
    a key may be any immutable type of object: it can be a number (integer or float), or even a string, but not a list;
    a dictionary is not a list - a list contains a set of numbered values, while a dictionary holds pairs of values;
    the len() function works for dictionaries, too - it returns the numbers of key-value elements in the dictionary;
    a dictionary is a one-way tool - can look for French equivalents of English terms, but not vice versa.
- dictionaries do not care about the order, not like lists
	* the order that your dictionary is stored in is completely out of your control
	* python 3.6 has made dictionaries ordered collections by default
- searching dictionaries:
	- must use key to find value associated
	- print(dictionary['cat'])
		- returns chat to terminal (translate to french)
	- item_1 = dictionary.get("cat")
	- must validate input because if key isn't in dictionary, will error
		- use 'in' & 'not in' keywords
- readability:
	- can format dictionaries to be easier to read:
	- ex:
		dictionary = {
              "cat": "chat",
              "dog": "chien",
              "horse": "cheval"
              }
- for loops & dictionaries:
	- dictionaries can't be itterated through using a for loop normally (not a sequence type)
	*** why does the course then give this in the summary?
		pol_eng_dictionary = {
		    "zamek": "castle",
		    "woda": "water",
		    "gleba": "soil"
		    }
		
		for item in pol_eng_dictionary:
		    print(item) 
		
		# outputs: zamek
		#          woda
		#          gleba


	- work arounds:
		- using the 'keys()' method
			- creates a list of the keys with values that's itterable
			- ex:
				dictionary = {"cat": "chat", "dog": "chien", "horse": "cheval"}
				for key in dictionary.keys():
				    print(key, "->", dictionary[key]

				* output:
					horse -> cheval
					dog -> chien
					cat -> chat
			- can also sort output of 'keys()' method
				- ex: for key in sorted(dictionary.keys()):

- 'items()' method:
	- using the 'items()' method on a dictionary returns tuples (each tuple = key-vlaue pair)
	- ex:
		for english, french in dictionary.items():
    		print(english, "->", french)
					
- 'values()' method:
	- same as 'keys()' method but returns only values
	- ex:
		for french in dictionary.values():
    		print(french)
		*result: 
			cheval
			chien
			chat

- modifying & adding values to dictionaries:
	- replacing values:
		- ex:
			dictionary = {"cat": "chat", "dog": "chien", "horse": "cheval"}
			dictionary['cat'] = 'minou'
	- adding new keys:
		- ex:
			dictionary = {"cat": "chat", "dog": "chien", "horse": "cheval"}
			dictionary['swan'] = 'cygne'
		*remember this is impossible with lists, cannot provide a non-existent entry
		- 'update()' method does the same thing as above:
			- ex:
				dictionary.update({"duck": "canard"})
	- removing a key:
		- ex:
			del dictionary['dog']
			*removing a non-existing key causes an error.
		- removing the last item in dictionary:
			- 'popitem()' method
			- ex:
				dictionary.popitem()

'tuple()' & 'list()" functions:
- ex: 
	my_tuple = tuple((1, 2, 3))	# tuple
	my_list = list(1, 2, 3) 	# list

'clear()' method:
	- ex: pol_eng_dictionary.clear()   # removes all the items

'copy()' method:
	- ex: copy_dictionary = pol_eng_dictionary.copy()

'count()' method:
	- ex: dictionary.count(2) # counts number of '2's' there are in the dictionary

Add two dictionaries into a new one:
	for item in (d1, d2):
    d3.update(item)



4.7
Exceptions

Types of Errors:
	- ValueError = invalid value entered
	- ZeroDivisionError
	- AttributeError = trying to activate a method for an item that doesn't use it
	- SyntaxError
	- TypeError = incorrect datatype 
		- input validation example: type(value) is int
	
'try' keyword:
	- place code that may be risky under try keyword
	- any error which occurs here won't terminate program execution.
		- control jumps to first line in 'except' and ends 'try' execution

'except' keyword:
	- can add code to try to handle exception from 'try'
	- code here is executed only when an exception occurs in 'try'

- example of try-except:
	try:
	    value = int(input('Enter a natural number: '))
	    print('The reciprocal of', value, 'is', 1/value)
	except:
	    print('I do not know what to do.')

Two exceptions after one try:
- must specify error types for exceptions
- ex: 
	try:
	    value = int(input('Enter a natural number: '))
	    print('The reciprocal of', value, 'is', 1/value)
	except ValueError:
	    print('I do not know what to do.')
	except ZeroDivisionError:
	    print('Division by zero is not allowed in our Universe.')

Debugger:
	- program that allows to execute one line of your code at a time

print/interactive debugging:
	- using the 'print()' function to see outputs





Resources:
Python Institute PCEP: https://pythoninstitute.org/pcep
PCEP Free Course by Python Institute: https://edube.org/study/pe1
PCEP Youtube Course: https://www.youtube.com/watch?v=Bewg-rmsV8w
All Python Built-in Functions: https://docs.python.org/3/library/functions.html
All Python Built-in Functions Explained: https://www.youtube.com/playlist?list=PL4eU-_ytIUt_s4S9aZ6rLoP7aAUkj66gx