CS1602计算导论
第 3 讲Part 1 计算基础与 Python 入门AI Level 0

字符串、变量与输入输出

Fundamentals II: Strings, Variables and I/O

程序要和人打交道:读进来的东西怎么处理,算出来的结果怎么呈现。这一讲还会引入函数,它是这门课里第一件用来对付复杂度的工具。

本讲结束后你应当能

  • 说清楚赋值语句在内存里做了什么,对比 C 和 Python 的变量,并用「名字指向对象」的模型解释 id() 和 is
  • 用 f-string 把变量嵌进输出,控制小数位和对齐
  • 处理 input() 返回的字符串,并知道转换失败时会发生什么
  • 定义带参数和返回值的函数
  • 分清 print() 和 return 的区别,不再混用
本页目录

变量

赋值做了什么

x = 10
print(x)

x = 20        # 同一个名字,指向新的值
print(x)

= 读作”把右边的值,绑定到左边的名字上”。它不是数学里的等号。数学里 x = x + 1 是个假命题,程序里却完全合法,意思是”把 x 现在的值加 1,再绑回 x”。

count = 5
count = count + 1     # 先算右边 5+1=6,再绑给 count
print(count)

命名规则

变量名必须以字母或下划线开头,后面可以跟字母、数字、下划线。区分大小写,age 和 Age 是两个变量。

不能用 Python 的关键字(if、for、class、return 这些)。

import keyword
print(keyword.kwlist)

比规则更重要的是习惯:

  • 用有意义的名字。student_count 比 n 好,n 比 x 好
  • 多个单词用下划线连接:max_score、user_name(这叫 snake_case,是 Python 的约定)
  • 不要用 l(小写 L)、O(大写 o)单独做变量名,它们和 1、0 长得太像

变量到底是什么

先看两段代码。左边是 C 语言,右边是 Python:

int x = 10;
x = 20;
x = 10
x = 20

C 这门课不学,这两行只需要看懂两点:C 在第一次使用 x 之前要写明它的类型,int 表示整数;每条语句以分号结尾。

两段代码运行完,x 都是 20。但执行 x = 20 的时候,原来那个 10 去了哪里?两种语言的回答不同。

内存是一长串字节,每个字节都有一个编号,这个编号叫地址。程序靠地址找到存在内存里的数据。

C 的变量就是一块内存。 int x = 10 让编译器划出 4 个字节,起名叫 x,10 就存在这 4 个字节里。x = 20 直接把这 4 个字节改写成 20,10 被覆盖了。这很像一个装东西的盒子。

Python 的变量是一个引用。 x = 10 做了两件事:先在内存里造一个整数对象 10,再让 x 记下这个对象的地址。x 里只存一个地址,不存 10。这种存着地址、指向某个对象的名字,叫做引用。x = 20 时,Python 另造一个整数对象 20,再让 x 改指过去。原来那个 10 没有被改写,只是 x 不再指向它。

C 里要实现「指向」,得专门用一种叫指针的写法,这门课不涉及。Python 里每个名字都是引用,不需要任何额外写法。

盒子的理解在只有数和字符串的时候推不出错误的结论,所以很多人一直这样理解 Python 的变量,直到 L5 的列表才发现解释不了。从一开始就用「名字 → 对象」的箭头来理解,后面会省很多事。

id() 返回一个名字当前指向的对象的地址:

x = 10
print(id(x))

y = x            # 让 y 指向同一个对象
print(id(y))
print(x is y)    # is 问的是「同一个吗」

y = x 之后两个 id 一样,说明赋值复制的是地址,不是对象本身。内存里只有一个 10,两个名字都指着它。

类型记在对象上

既然 x 里只有一个地址,Python 怎么知道它是整数还是字符串?

L2 画过 n = 2026 在内存里的样子:这个对象一共 28 个字节,其中第 8 到 15 字节记着它的类型。类型是对象自己带着的。 type(x) 做的事情,就是顺着 x 里的地址找到对象,再读出类型那一栏。

所以 Python 不用声明类型,同一个名字可以先后指向不同类型的对象:

x = 2026
print(type(x))

x = "2026"       # 同一个名字,现在指向一个字符串
print(type(x))

C 的做法相反:那 4 个字节里只有值,没有任何记录说明它是整数。类型只写在代码里,也就是 int x 的那个 int,编译器据此决定怎么解读这 4 个字节。所以 C 的变量必须先声明类型,声明之后也不能再改。

不可变的对象可以放心共用

y = x 之后,两个名字指向同一个 10。那么改了 x,y 会不会跟着变?

x = 10
y = x            # y 和 x 指向同一个 10
x = x + 1        # 另造一个 11,再让 x 指过去

print(x, y)
print(x is y)

不会。整数对象造出来之后就不能再改,这叫不可变。字符串也是不可变的,下一节会看到。x = x + 1 并没有把那个 10 改成 11,而是另造了一个 11,再让 x 指过去,y 还指着原来的 10。

正因为不可变,Python 可以放心地让很多名字共用一个对象,省去复制。CPython 还会预先造好 -5 到 256 这些常用的小整数,程序里用到的 10 都是同一个对象。

L5 的列表可以原地修改。到那时,两个名字共用一个列表,改了其中一个,另一个也会跟着变。

== 和 is 不是一回事

== 问「值相不相等」,is 问「是不是同一个对象」:

a = [1, 2, 3]
b = [1, 2, 3]

print(a == b)     # 内容一样
print(a is b)     # 但不是同一个

(方括号那个写法叫列表,L5 讲。这里借它说明问题,因为小整数会被共用,在数上演示 is 结果说不准。)

两个列表内容完全一样,所以 == 是 True;但它们是分别造出来的两个对象,在内存里占两块地方,所以 is 是 False。就像两张一模一样的复印件:内容相同,不是同一张纸。

两种设计的取舍

CPython
存一个整数4 字节8 字节地址 + 28 字节对象
取值直接读这 4 个字节先读地址,再找到对象
类型记在哪变量上:必须先声明对象上:不用声明
赋值一个大对象把内容整块复制只复制 8 字节的地址
要留神的地方类型定死,不能换几个名字共用一个可变对象
设计出发点贴近硬件,速度优先规则统一,写起来灵活

(字节数是 64 位电脑上 CPython 的情况。)

单看一个数,Python 多用了好几倍的空间,速度也慢一些。换来的是:类型跟着对象走,名字不用声明类型;所有东西按同一种方式处理,不论指向的是一个整数还是一个装了一百万个数的列表,赋值都只复制一个地址;连函数都可以像数一样赋给一个名字,本讲后面会看到。

C 的设计出发点是贴近硬件、运行得快,写操作系统、写嵌入式程序的人需要这一点。Python 的出发点是写起来省事:现在的计算机足够快,多数程序慢一点没有关系,程序员的时间更宝贵。

代价是几个名字可以共用一个对象。对不可变的整数和字符串,这不会带来问题;L5 讲列表时,这会带来一些初看很意外的现象,那一讲一大半内容都是这个模型的直接后果。

同步赋值

等号左右都可以是用逗号隔开的多项,数量对上就行:

a, b = 7, 3
print(a, b)

# 右边先整个算完,再一一对应绑过去
# 所以交换两个变量不需要中间变量
a, b = b, a
print(a, b)

关键是执行顺序:右边先整体求值,然后才一一绑给左边的名字。 所以 a, b = b, a 里,右边先取到当前的 b 和 a(3 和 7),再绑回去,交换就完成了。

在 C、Java 里交换两个变量需要一个临时变量。这个写法后面会反复出现:函数返回多个值时用它接住,L4 的循环里配 enumerate 时也用它。

复合赋值

x = 10
x += 3      # 等价于 x = x + 3
print(x)

x -= 5
x *= 2
x //= 3
print(x)

+=、-=、*=、/=、//=、%=、**= 都可以这么用。写法更短,而且左边的名字只出现一次,改名字的时候不容易漏。

注释

# 后面到行末的内容 Python 会跳过,是写给人看的。

# 这一行是注释
x = 3.14   # 也可以写在语句后面
print(x)

好的注释解释为什么,而不是复述代码在做什么:

# 差:把 x 加一
x += 1

# 好:跳过表头那一行
x += 1

字符串

三种引号

a = 'single'
b = "double"
c = """三个引号
可以跨行
写多行文本"""

print(a, b)
print(c)

单引号和双引号完全等价。有一个实用差别:字符串里要用哪种引号,外面就用另一种,省得写转义。

print("他说:'你好'")
print('She said "hello"')

拼接与重复

first = "Shanghai"
second = "Jiao Tong"

print(first + " " + second)     # + 拼接
print("-" * 30)                 # * 重复
print(first * 2)

长度、索引与比较

s = "Python"

print(len(s))       # 长度
print(s[0])         # 第一个字符,下标从 0 开始
print(s[-1])        # 最后一个
print(s[2:5])       # 切片,含头不含尾

字符串的比较是按字典序,逐个字符比 ASCII 编号:

print("apple" < "banana")     # 'a' < 'b'
print("Zebra" < "apple")      # 大写字母编号更小
print("abc" < "abd")          # 前两个相同,比第三个
print("abc" < "abcd")         # 前缀更短的在前

字符串是不可变的,不能改其中某个字符:

s = "hello"
s = s.upper()        # 这是生成新字符串,再绑给 s
print(s)
s = "hello"
s[0] = "H"           # 这个会报错

字符串的完整方法在 L6 集中讲,这里够用了。

类型转换

type() 给出一个值的类型:

print(type(42))
print(type(3.14))
print(type("42"))
print(type(True))

int()、float()、str() 在类型之间转换:

print(int("42") + 1)        # 字符串 → 整数
print(float("3.14") * 2)    # 字符串 → 浮点数
print(str(42) + "!")        # 数 → 字符串
print(int(3.99))            # 浮点数 → 整数,直接截断,不是四舍五入
print(int(-3.99))           # 负数也是往零的方向截断

转换失败会抛异常:

print(int("abc"))
print(int("3.14"))     # 这个也会失败,猜猜为什么

int() 只认整数形式的字符串。"3.14" 要先 float() 再 int()。

输入

input() 永远返回字符串

# 讲义里点运行会弹出输入框,试着输入一个数字
age = input("请输入你的年龄:")
print("你输入的是:", age)
print("它的类型是:", type(age))

无论输入什么,input() 拿到的都是字符串。 输入 18,得到的是 "18" 而不是 18。

这是新生第一个月最高频的错误:

n = input("输入一个数:")
print(n * 3)        # 想要三倍,得到的是重复三遍

正确写法是先转换:

n = int(input("输入一个数:"))
print(n * 3)

eval():能用,但要小心

eval() 把字符串当成 Python 表达式来求值:

print(eval("1 + 2 * 3"))
print(eval("2 ** 10"))

看起来很方便:eval(input()) 就能直接读进一个数,或者一个表达式。

格式化输出

为什么需要它

要输出”张三的成绩是 87.5 分”,其中姓名和分数来自变量。用拼接写出来很啰嗦:

name = "张三"
score = 87.5
print(name + "的成绩是 " + str(score) + " 分")     # 能用,但很啰嗦

f-string(这门课的主线写法)

在引号前面加一个 f,然后把变量直接写进 {} 里:

name = "张三"
score = 87.5

print(f"{name}的成绩是 {score} 分")

{} 里可以放任何表达式:

a, b = 7, 3
print(f"{a} + {b} = {a + b}")
print(f"{a} / {b} = {a / b}")
print(f"平均分:{(87 + 92 + 65) / 3}")

控制格式

{值:格式} 可以控制小数位、宽度、对齐、进制:

pi = 3.14159265

print(f"{pi:.2f}")        # 保留两位小数
print(f"{pi:.4f}")
print(f"{pi:10.2f}")      # 总宽度 10,右对齐
print(f"{pi:<10.2f}|")    # 左对齐
print(f"{pi:^10.2f}|")    # 居中
n = 255
print(f"{n:b}")           # 二进制
print(f"{n:o}")           # 八进制
print(f"{n:x}")           # 十六进制小写
print(f"{n:X}")           # 十六进制大写

m = 5
print(f"{m:b}")           # 101
print(f"{m:08b}")         # 补零到 8 位,对齐时很有用
# 对齐输出一张表
data = [("张三", 87.5), ("李四", 92.0), ("王五", 65.25)]
for name, score in data:
    print(f"{name:<6}{score:>8.1f}")

调试时有个很好用的写法:{变量=} 会同时打印名字和值。

x = 42
y = [1, 2, 3]
print(f"{x=}")
print(f"{y=}")

你会在旧代码里见到的两种写法

f-string 之前,Python 还有过两代格式化语法。这门课统一用 f-string,但你读别人的代码会遇到前两代:

name, score = "张三", 87.5

# 第一代:C 风格的 %
print("%s的成绩是 %.1f 分" % (name, score))

# 第二代:str.format()
print("{}的成绩是 {:.1f} 分".format(name, score))

# 第三代:f-string —— 用这个
print(f"{name}的成绩是 {score:.1f} 分")

三行输出一样。f-string 的优势是变量就写在它出现的位置,不用在末尾的括号里数第几个参数对应第几个占位符。

print("a", "b", "c")                  # 默认用空格分隔
print("a", "b", "c", sep="")          # 不分隔
print("a", "b", "c", sep=" | ")       # 自定义分隔符
print("2026", "09", "14", sep="-")

print("不换行", end="")
print(" ← 接上了")

函数

为什么需要函数

设想你要写一个几千行的程序。如果全部平铺在一个文件里从上写到下:

  • 改动一处很难确定会不会影响别处
  • 同样的逻辑要复制粘贴很多遍,改的时候容易漏
  • 别人(包括三个月后的你)读不懂

函数是把一段逻辑打包起来、起个名字、以后直接用名字调用。 它是编程里对抗复杂度最基本的手段:把大问题切成小问题,一次只想一个。

你已经在用别人写好的函数了:print()、len()、int()、input()。现在学着写自己的。

三要素:名字、参数、返回值

def area_of_circle(r):        # def 关键字,函数名,参数
    return 3.14159 * r * r    # 返回值

print(area_of_circle(1))
print(area_of_circle(2.5))
  • def 是定义函数的关键字
  • area_of_circle 是函数名,命名规则和变量一样
  • r 是参数(parameter),是这个函数的输入
  • return 后面是返回值,是这个函数的输出
  • 函数体要缩进

缩进决定了什么属于函数

Python 用缩进划分代码块。连续的、相同缩进的语句属于同一块。

def greet(name):
    print("准备打招呼")       # 缩进了,属于函数
    print(f"你好,{name}")    # 缩进了,属于函数

print("这一行没缩进,不属于函数")   # 函数外
greet("张三")

约定是每级缩进 4 个空格。不要混用 Tab 和空格,看起来一样,Python 却会报错。VS Code 默认会把 Tab 转成空格,一般不用操心。

定义了不等于运行了

def say_hi():
    print("hi")

print("程序开始")
# 上面定义了函数,但一次都没运行
say_hi()          # 现在才运行
say_hi()          # 可以运行很多次

函数定义只是”登记”这段代码,不会执行它。 只有调用(写上名字加括号)才会运行。

而且必须先定义后调用:

say_bye()

def say_bye():
    print("bye")

函数也是对象

Python 里每个名字都是引用,函数名也不例外。执行到 def 这一行时,Python 把函数体做成一个函数对象,再让函数名指向它。所以函数也可以赋给别的名字:

def square(n):
    return n * n

f = square        # 没有括号:不是调用
print(f(7))
print(f is square)
print(type(square))

f = square 和 y = x 是同一回事:f 和 square 指向同一个函数对象。

注意 f = square 后面没有括号。不加括号,得到的是函数本身;加上括号,就是调用它、拿到返回值。 如果写成 f = square(7),意思就变成先调用,再把返回值 49 绑给 f。

前面说不要拿 list 当变量名,原因也在这里:list 本来指向一个内置函数,list = [1, 2, 3] 让它改指一个列表,那个内置函数就无法再调用了。

函数能赋给名字,也就能当作参数传给别的函数。L6 用 sorted 排序时,key=len 就是把 len 这个函数传进去。

参数传递

调用时括号里的值叫实参(argument),会按顺序绑给定义时的形参(parameter):

def rectangle_area(width, height):
    return width * height

print(rectangle_area(3, 4))      # width=3, height=4
print(rectangle_area(4, 3))      # 顺序不同,这里结果一样但不总是

也可以指定名字,这样顺序就无所谓了:

def introduce(name, age, city):
    print(f"{name},{age} 岁,来自{city}")

introduce("张三", 18, "上海")
introduce(age=18, city="上海", name="张三")     # 用名字指定

函数内外是隔离的

函数里定义的变量叫局部变量,函数一结束就消失,外面看不见:

def compute():
    result = 42          # 局部变量
    return result

print(compute())
print(result)            # 报错:外面没有 result

这个隔离是好事。写函数的时候不必担心变量名和别处重复:

def f():
    x = "函数里的 x"
    return x

x = "外面的 x"
print(f())
print(x)        # 没被函数改掉

完整的作用域规则(LEGB)在 L14 讲,现在记住”函数内外互不干扰”就够了。

return

return 做两件事:把值交回调用处,并立即结束函数。

def check(n):
    if n > 0:
        return "正数"
    return "非正数"        # 只有上面没 return 时才会执行

print(check(5))
print(check(-3))

return 后面的代码不会执行:

def demo():
    print("这行会执行")
    return 1
    print("这行永远不会执行")

print(demo())

可以一次返回多个值(实际上是打包成一个元组):

def min_max(a, b, c):
    return min(a, b, c), max(a, b, c)

low, high = min_max(3, 9, 5)
print(low, high)

没有 return 的函数,返回值是 None:

def no_return():
    print("我只打印,不返回")

result = no_return()
print("返回值是:", result)
print("类型是:", type(result))

这是新生最高频、也最难自己发现的错误。 请认真读这一节。

它们看起来都”给出了结果”,做的却是完全不同的事:

  • print() 是给人看的,把内容显示在屏幕上。显示完就没了,程序里拿不到
  • return 是给程序用的,把值交回调用的地方,可以接着参与计算

看两个几乎一样的函数:

def add_print(a, b):
    print(a + b)          # 只显示

def add_return(a, b):
    return a + b          # 交回值

add_print(2, 3)           # 屏幕上出现 5
add_return(2, 3)          # 屏幕上什么都没有

第二个函数明明算对了,屏幕上却什么都没有:它把值交回来了,而调用处没有接住。

区别在于能不能接着用:

def add_print(a, b):
    print(a + b)

def add_return(a, b):
    return a + b

x = add_print(2, 3)       # 屏幕显示 5,但 x 是 None
y = add_return(2, 3)      # 屏幕没显示,但 y 是 5

print("x =", x)
print("y =", y)
def add_print(a, b):
    print(a + b)

# 想拿结果继续算,就彻底失败了
total = add_print(2, 3) + add_print(4, 5)

None + None 直接报 TypeError。

这条规则还有一个直接的现实后果:实验平台是调用你提交的函数、比对返回值来判分的。 用 print 不 return 的函数,平台拿到的是 None,一分不得。哪怕算法完全正确,屏幕上看起来也完全对。

用别人写好的函数

Python 自带大量现成的函数,按模块组织。用 import 引入:

import math

print(math.sqrt(16))        # 开方
print(math.pi)              # 圆周率
print(math.floor(3.7))      # 向下取整
print(math.ceil(3.2))       # 向上取整
print(math.factorial(5))    # 阶乘

有了 math.pi,前面那个圆面积函数可以写得更准:

import math

def area_of_circle(r):
    return math.pi * r ** 2

print(f"{area_of_circle(1):.6f}")

模块的完整机制(怎么找、怎么装、怎么写自己的)在 L10。现在你只要会 import 模块名 然后 模块名.函数名() 就够了。

小结

  • = 是绑定,读作”变成”。Python 的变量是引用:名字里存着地址,指向内存里的对象
  • C 的变量就是一块内存,类型要先声明;Python 的类型记在对象上,所以不用声明
  • 整数和字符串不可变,「改」都是造新对象再改指,所以可以放心共用
  • id() 给地址,is 问是不是同一个。比值用 ==,只有和 None 比才用 is
  • 同步赋值 a, b = b, a:右边先整体求值,所以交换不需要临时变量
  • 变量名要有意义,用 snake_case,别覆盖 list、str、sum 这些内置名
  • 字符串按字典序比较,大写字母排在小写前面;字符串不可变
  • int() 截断;round() 取最接近的整数,正好一半时往偶数靠(银行家舍入)
  • input() 永远返回字符串,要数就得自己转
  • eval() 危险,需要数就用 int() / float()
  • 格式化输出统一用 f-string,{值:.2f} 控小数位,{值:>10} 控对齐
  • 函数三要素:名字、参数、返回值。定义不等于运行,必须先定义后调用
  • 函数也是对象:f = square 让两个名字指向同一个函数,加上括号是调用
  • 函数内外隔离,局部变量出了函数就没了
  • print 给人看,return 给程序用。函数内部一律 return

练习

  1. 写一个函数 celsius_to_fahrenheit(c),把摄氏度转成华氏度(公式 F = C * 9/5 + 32),返回结果保留一位小数。注意是 return 不是 print。
  2. 写一个程序,读入两个整数,输出它们的和、差、积、商(保留两位小数)。用 f-string 输出,格式为 12 + 5 = 17。
  3. 下面这个函数有什么问题?改对它:
    def double(n):
        print(n * 2)
    
    result = double(21)
    print(result + 1)
  4. 写一个函数 initials(full_name),输入 "Zhang San Feng",返回 "Z.S.F."。(提示:用 .split() 把字符串按空格切开)
  5. 用 f-string 输出一个九九乘法表的前三行,每个乘积占 4 格宽度右对齐。
  6. 解释下面两段代码为什么结果不同:
    x = 10
    def f():
        x = 20
    f()
    print(x)          # 输出什么?
    
    x = 10
    def g():
        return 20
    x = g()
    print(x)          # 输出什么?

本讲的配套上机题在 Lab 3。