PHP 语言基础

理解 PHP 文件如何执行、表达式怎样产生值,以及如何用条件、循环和函数写出可预测的程序。

难度 入门 时长 标准深度约 17分钟
版本 PHP 8.3.33
what

PHP 程序由按顺序执行的语句组成;表达式产生值,条件、循环与函数决定这些值如何参与后续执行。

trap

自动类型转换、宽松比较以及 andor 的低优先级会让看似直白的条件产生意外结果。

fix

文件入口启用严格类型,比较时优先使用 ===,复杂表达式拆成有名称的步骤,并让函数边界写明参数与返回类型。

是什么,为什么存在

PHP 语言基础是一组让源码变成行为的规则:文件边界、语句、表达式、变量、运算符、控制结构与函数。一个 PHP 文件可以是命令行脚本,也可以由 Web 服务器交给 PHP 运行时处理。无论入口来自哪里,运行时都会解析代码并从顶层开始执行。

一条 语句(statement) 是一个完整的执行单位,例如赋值、函数调用或 return 表达式(expression) 会产生值,例如 $price * $quantity$user ?? 'guest' 或一次函数调用。赋值本身也是表达式,所以它既改变绑定,也产生被赋的值。

这些规则解决两个问题。第一,程序需要组合与转换数据;第二,程序需要根据结果选择路径、重复工作,并把一段行为命名后复用。你会在控制器、命令行命令、模板入口、队列任务和测试中反复遇到这些基本结构。

「基础」不等于把整门语言压成语法清单。数组形状、类型转换细节、对象模型和异常处理分别有独立主题。这里关注一条更窄的主线:PHP 怎样执行一组操作,以及怎样让这组操作容易推理。

工作原理

文件、标签与语句

纯 PHP 文件以 <?php 开始,通常省略结尾的 ?>。这样可以避免结束标签后的空白意外进入响应。包含 HTML 的模板可以离开再进入 PHP 模式,但应用类、配置和命令行脚本通常只保留一个开标签。

大多数简单语句以分号结束。代码块使用花括号把多条语句交给 ifforeach 或函数等结构。花括号组织控制流,但不会像某些语言那样为普通变量创建新的块级作用域。

顶层语句按运行路径执行。函数声明先定义可调用行为;函数体只有在发生调用时才执行。return 在函数中结束本次调用并交回值,在被直接包含的文件中则结束该文件的执行并把值交给包含方。

declare(strict_types=1); 必须是文件的第一条语句,位于开标签之后。它为该文件发起的用户自定义函数调用收紧标量参数转换。它不是输入验证器,也不会改变 == 的比较规则。

名称、值与赋值

变量名以 $ 开头,并区分大小写。赋值把右侧求出的值绑定给左侧名称;之后再次赋值可以换成另一种类型的值。运行时类型属于值,而不是永久属于变量名,具体转换规则由 php/data-types 主题展开。

普通赋值的具体语义取决于值的种类。标量与数组表现为值,赋给另一个变量后再修改副本不会改动原变量;对象变量保存指向对象的句柄。引用运算符 & 会建立变量别名,不能把它当成普通对象赋值的同义写法。

变量有作用域。函数内部创建的普通变量属于该函数;外部同名变量不会自动进入函数。global 可以连接到全局名称,但它会隐藏依赖,通常更清楚的做法是把值作为参数传入,再把结果返回。

变量名称与语言关键字采用不同规则。$order$Order 是两个变量,而 ifIFIf 会被识别为同一个关键字。团队代码仍应采用一种稳定大小写,不要把关键字或函数名的大小写宽容当成命名技巧。

不会在运行期间重新绑定的名称可以声明为常量。文件或命名空间级常量可使用 const TAX_RATE = 0.2;define() 则在运行时定义常量。常量适合真正稳定的程序配置;部署环境、密钥和每次请求变化的值不应硬编码成常量。

字符串可以用单引号或双引号表示。双引号字符串会处理变量插值与 \n 等转义序列,单引号字符串只对反斜杠和单引号执行有限转义。插值变复杂时,先求出值再用 sprintf() 格式化,往往比在字符串中堆叠花括号更清楚。

表达式与运算符

PHP 先根据运算符优先级把表达式分组,再对分组后的部分求值。乘法的优先级高于加法,所以 2 + 3 * 4 等于 14。如果业务规则比语法规则重要,括号能把意图直接写出来,例如 ($subtotal + $shipping) * $taxRate

常见运算符可以按用途理解,而不必一次背完所有符号。

用途运算符结果
算术+-*/%**数值
字符串连接.字符串
比较===!==<>=<=>布尔值或比较整数
逻辑&&、`
默认值??左值存在且不为 null 时取左值,否则取右值
条件值条件 ? 真值 : 假值被选择的分支值

=== 同时比较类型和值,== 会先应用宽松比较规则。外部输入经常以字符串进入程序,所以 $input === 0$input == 0 回答的不是同一个问题。先把输入验证并规范化为目标类型,再做严格比较,通常最容易审查。

&&|| 会执行 短路求值(short-circuit evaluation) 。左侧已经决定结果时,右侧不会运行。这适合先检查前置条件再调用后续函数,但右侧若藏有必须发生的写入,代码就会变得脆弱。

输出与字符串边界

PHP 使用点号 . 连接字符串,不使用 ++ 会进入数值运算规则,把其他语言中的字符串拼接写法直接搬过来,可能得到类型错误或完全不同的数值。插值适合短小的双引号字符串,多个带格式的字段则适合 printf()sprintf()

echo 是语言结构,可以直接输出一个或多个字符串;print 也会输出字符串,并返回整数 1。返回值通常没有业务意义。需要固定小数位、字段宽度或其他格式时,格式化函数把要求集中在格式字符串中,更容易对照预期输出。

输出值不等于安全地生成 HTML、JSON、SQL 或 shell 命令。每种目标格式都有自己的编码规则,同一套「转义特殊字符」不能跨上下文复用。语言基础只负责产生字符串;真正写到外部边界前,还要使用该边界对应的编码器或参数化 API。

CLI 中的 PHP_EOL 表示平台行尾,双引号字符串中的 \n 是换行字符。协议输出如果明确要求 LF,应直接使用 "\n";面向当前终端的人类可读输出可以使用 PHP_EOL。两者不是风格上的同义词。

控制结构

ifelseifelse 根据条件选择路径。条件会被转换为布尔值,因此空字符串、字符串 '0'、整数 0、空数组、nullfalse 都会进入假分支。若这些值在领域中含义不同,应先做明确比较,而不是只依赖真值。

match 根据一个主题值选择并返回分支值。它使用严格比较、不会贯穿到下一个分支,并且可以用 default 处理未列出的值。switch 仍适合既有代码,但它使用宽松比较,而且每个非空分支通常需要显式 break

while 在每轮开始前检查条件,do ... while 至少执行一次,for 把初始化、条件与步进放在同一处。foreach 直接遍历数组或 Traversable 值,通常比手工维护索引更清楚。循环体必须让终止条件有机会发生,否则任务会一直占用执行进程。

break 结束当前循环或 switchcontinue 跳过本轮剩余语句并进入下一轮。嵌套结构允许使用数字参数跳出多层,但这种写法很难扫描。把内层行为提取为函数并尽早 return,通常能让退出路径更直白。

控制结构的选择应从代码要回答的问题出发。

问题首选结构退出方式
条件成立时是否执行if分支结束
一个值对应哪个结果match表达式产生结果
条件为真时是否继续while条件变假或 break
工作是否至少执行一次do ... while条件变假或 break
计数器是否仍在范围内for条件变假或 break
容器中是否还有元素foreach遍历结束或 break

这张表不是替代规则。例如,复杂的 for 可以改成 while,返回布尔值的 match 也可以写成 if 链。选择让终止条件、分支顺序与结果类型最容易看见的形式。

每种结构都需要覆盖边界。循环至少测试空输入、一个元素和触发退出条件的输入;分支则测试每个明确分支与默认路径。只运行「最常见」输入,会让错序条件和永远到不了的分支留在代码中。

函数边界

函数把一组语句放进带名称的调用边界。参数接收调用方提供的值,return 把结果交回调用方。函数没有执行 return,或只执行空的 return; 时,结果是 null

参数可以有默认值,也可以写类型声明;函数还可以声明返回类型。必填参数应放在可选参数之前。调用方可以按位置传参,也可以在 PHP 8 中使用命名参数,但公开参数名此后就成为调用契约的一部分。

参数默认按值传递。只有形参显式带 & 时,函数才能通过该参数重新绑定调用方变量。引用传参会扩大函数的副作用范围,应只用于接口明确要求原地修改的情况。

函数越小越容易检查,但「小」不是按行数机械切割。一个函数应完成一个能准确命名的任务,把输入要求写在边界上,并让正常返回值保持一种稳定含义。错误路径则交给 php/error-handlingphp/exceptions 中的机制。

入口与失败边界

CLI 脚本通常从 $argv 接收字符串参数,并把文本写到标准输出或标准错误。Web 请求则通过服务器接口提供查询参数、表单、Cookie、文件和请求头。来源不同,语言语法相同;外部值都必须先经过解析与验证。

$_GET$_POST$_SERVER 等超全局变量可以在函数内直接读取,但这不表示它们适合成为隐藏依赖。入口应读取所需字段,转换成明确参数,再调用不依赖请求环境的业务函数。

同一业务函数若能接收普通值并返回普通值,就可以同时服务 Web、CLI 和测试。入口负责协议细节,业务函数负责规则;这条边界也让失败该在哪里转换成响应或退出码变得清楚。

解析错误会阻止相应代码正常开始执行。运行期间还可能出现 TypeErrorValueError、除零错误与应用抛出的异常。PHP 8 中许多这类失败实现 Throwable,但并非每条诊断消息都会自动成为异常。

基础代码应先让正常数据流清楚,再在拥有恢复策略的边界处理失败。局部函数无法恢复时,不要返回一个与合法结果混淆的假值。具体的诊断级别、异常链与生产日志策略由错误处理主题说明。

退出码也是 CLI 契约的一部分。成功通常返回 0,失败使用非零值,并把面向人的错误消息写到标准错误。脚本即使打印了「failed」,只要退出码仍是 0,自动化调用方就可能把它当成成功。

示例

下面四个示例沿着同一条路径展开:先计算值,再根据值选择订单状态,随后用循环汇总库存,最后把重复规则收进函数。所有输出都由本地 PHP 8.3.33 CLI 实际生成。

组合表达式计算订单金额

第一个脚本用算术、逻辑与条件表达式计算会员订单。中间结果都有名称,因此每一步都可以单独检查。

invoice_total.php
<?php
declare(strict_types=1);

$unitPrice = 19.90;
$quantity = 3;
$isMember = true;

$subtotal = $unitPrice * $quantity;
$discountRate = $isMember && $subtotal >= 50.0 ? 0.10 : 0.0;
$total = $subtotal * (1 - $discountRate);

printf("subtotal=%.2f\n", $subtotal);
printf("discount=%.0f%%\n", $discountRate * 100);
printf("total=%.2f\n", $total);
subtotal=59.70
discount=10%
total=53.73

* 在赋值前完成,比较又在 && 之前按优先级组合。折扣表达式虽然可以继续压缩,但把小计与折扣率保留为独立名称后,金额规则更容易测试。

示例用 printf() 控制显示精度,不表示二进制浮点数适合所有货币计算。真正的结算系统通常使用最小货币单位的整数,或选定并测试十进制定点方案。

用控制流分类订单

第二个脚本遍历订单,并用 match (true) 按优先级选择状态。分支从最需要处理的条件排到默认状态。

classify_orders.php
<?php
declare(strict_types=1);

$orders = [
    ['id' => 'A-100', 'paid' => false, 'total' => 80],
    ['id' => 'A-101', 'paid' => true, 'total' => 140],
    ['id' => 'A-102', 'paid' => true, 'total' => 35],
];

foreach ($orders as $order) {
    $status = match (true) {
        !$order['paid'] => 'payment-pending',
        $order['total'] >= 100 => 'priority',
        default => 'ready',
    };

    printf("%s: %s\n", $order['id'], $status);
}
A-100: payment-pending
A-101: priority
A-102: ready

match (true) 的每个分支条件都产生布尔值,并与主题值 true 严格比较。未付款检查必须在高金额检查之前,否则一个未付款的大额订单会过早进入 priority 分支。

这里的订单数据保持很小,只用于展示控制流。生产代码应在输入边界验证键和值,再把稳定的数据形状交给分类规则;数组形状的细节见 php/arrays

在循环中跳过无库存项

第三个脚本计算每个 SKU 扣除预留数量后的可用库存。continue 把无可用库存的分支立即结束,主路径只处理能够发货的项目。

available_stock.php
<?php
declare(strict_types=1);

$stockRows = [
    ['sku' => 'BK-1', 'available' => 4, 'reserved' => 1],
    ['sku' => 'PN-2', 'available' => 2, 'reserved' => 2],
    ['sku' => 'NT-3', 'available' => 7, 'reserved' => 3],
];

$readyUnits = 0;

foreach ($stockRows as $row) {
    $freeUnits = $row['available'] - $row['reserved'];

    if ($freeUnits <= 0) {
        printf("%s: skipped\n", $row['sku']);
        continue;
    }

    $readyUnits += $freeUnits;
    printf("%s: %d ready\n", $row['sku'], $freeUnits);
}

printf("total: %d ready\n", $readyUnits);
BK-1: 3 ready
PN-2: skipped
NT-3: 4 ready
total: 7 ready

循环先计算本轮局部结果,再决定是跳过还是累计。$readyUnits 的更新只有一个位置,避免了不同分支各自修改总数时常见的漏算与重复计算。

这里没有按引用遍历,因为脚本只读取库存行。需要产生更新结果时,可以明确构造新数组;若选择 & 原地修改,就要处理循环变量残留引用的陷阱。

把配送规则收进函数

第四个脚本把费用计算与格式化分开。参数和返回类型让每个函数的边界可见,调用处则使用命名参数表达各个字符串的角色。

shipping_summary.php
<?php
declare(strict_types=1);

function shippingFee(float $subtotal, string $zone): float
{
    if ($subtotal >= 100.0) {
        return 0.0;
    }

    return match ($zone) {
        'local' => 5.0,
        'regional' => 9.5,
        default => 15.0,
    };
}

function orderSummary(string $orderId, float $subtotal, string $zone): string
{
    $fee = shippingFee($subtotal, $zone);
    return sprintf('%s subtotal=%.2f shipping=%.2f', $orderId, $subtotal, $fee);
}

echo orderSummary(orderId: 'A-103', subtotal: 72.5, zone: 'regional'), PHP_EOL;
echo orderSummary(orderId: 'A-104', subtotal: 120.0, zone: 'local'), PHP_EOL;
A-103 subtotal=72.50 shipping=9.50
A-104 subtotal=120.00 shipping=0.00

免运费条件用提前 return 结束函数,后面的 match 只处理仍需收费的订单。两个函数都返回稳定类型,不通过全局变量或引用参数偷偷传递结果。

命名参数让调用易读,也把 orderIdsubtotalzone 变成外部可见名称。库作者重命名公开参数可能破坏调用方,因此内部 API 如果无需这项耦合,也可以继续使用位置参数。

陷阱

修复方法: 判断值时使用 ===,并让赋值单独占一条语句。若确实需要在条件中接收函数结果,使用显式括号与能说明意图的名称,同时为真假两条路径写测试。

修复方法: 组合布尔条件时优先使用 &&||,并在混合赋值、比较和逻辑运算时加括号。不要只凭英文读法猜分组方式;需要确认时查运算符优先级表。

修复方法: 先检查键是否存在,再验证允许的原始表示,最后转换为领域值。业务代码接收规范化后的类型,并用 === 区分必须保留的状态。

修复方法: 每个不应贯穿的分支都显式结束,或者在需要返回一个值时改用 match。迁移前要测试字符串数字、整数与 null 等边界值,因为严格匹配可能改变旧行为。

修复方法: 只在确实要原地修改时按引用遍历,并在循环后立刻执行 unset($item)。更容易审查的方案是构造新数组,或把单项变换交给返回新值的函数。

深入 求值顺序与副作用

求值顺序与副作用

运算符优先级说明表达式怎样分组,结合方向说明相同优先级的运算符怎样嵌套。两者都不等于子表达式的运行顺序。PHP 通常不规定一般表达式中各个子表达式谁先求值,因此代码不能依赖某次观察到的先后关系。

例如,不要在同一表达式中既读取 $index,又用 $index++ 改变它,并期待数组访问固定使用旧值或新值。也不要把两个必须按顺序发生的函数调用放在同一个算术表达式中。把每个有副作用的操作拆成独立语句,顺序就由语句排列清楚表达。

短路逻辑是明确的例外。$user !== null && $user->isActive() 只有在左侧为真时才调用方法;$cached ?? loadValue() 只有在左侧不存在或为 null 时才调用后备函数。这种条件执行是运算符契约的一部分,可以依赖。

条件表达式也只求值被选择的分支。它适合在两个无副作用的值之间选择;分支包含日志、写入或复杂调用时,普通 if 更容易看出发生了什么。可读性在这里与正确性直接相关,因为审查者需要看见哪些操作一定执行、哪些可能跳过。

赋值表达式会返回所赋的值,所以链式赋值和条件内赋值在语法上成立。这项能力偶尔能让解析循环变得紧凑,也会让 ==== 的拼写错误躲过语法检查。代码生成器若使用这种形式,应同时解释赋值发生的位置,并提供能让错误分支执行的测试。

函数参数求值也可能包含副作用。不要让正确性依赖多个参数调用谁先发生;先分别求出有名称的值,再调用函数。命名参数改变参数的对应方式,不应被当成调整求值顺序的工具。

文件包含与作用域

includerequire 会求值另一个 PHP 文件。被包含文件中的普通顶层代码会在包含发生时运行,所以包含不是只读取声明的静态操作。重复包含带副作用的文件,可能重复注册处理器、写入输出或执行初始化。

包含位置会影响被包含代码中的变量作用域。在函数内部执行 include 时,被包含文件能看到该位置可见的函数局部变量;它在该文件中创建的普通变量也留在相同函数作用域。依赖这种隐式共享会让文件无法单独理解,应尽量传入明确数据或返回明确结果。

被包含文件可以执行 return,结束自己的处理并把值交给 include 表达式。没有显式返回值的成功包含通常得到 1。把配置文件设计成 return 一个数组,比让它悄悄修改若干外部变量更容易测试。

include 找不到文件时会发出警告,当前脚本仍可能继续;require 失败则会中止这条执行路径。代码只有在缺少文件仍能提供明确后备行为时才应使用 include。程序启动所需的定义与配置通常属于 require 的语义。

include_oncerequire_once 会避免同一文件被再次包含,但它们不能修复含糊的初始化所有权。现代应用通常让 Composer 自动加载类,而不是在业务函数中散布手工路径。自动加载解决类文件定位,配置和有副作用的启动代码仍需要明确入口。

文件路径不能直接来自不可信输入。把请求参数拼进 include 会把数据选择变成代码或文件选择,可能造成任意本地文件读取或执行。允许的模块应由固定映射决定,映射之外的值直接拒绝。

包含机制解释了许多旧式 PHP 项目的运行方式,也说明了为什么顶层副作用难以测试。新的代码可以让入口文件只负责装配:加载自动加载器与配置,创建依赖,然后调用一个明确的应用函数。这样,执行从哪里开始、哪些值流入业务逻辑,都能在少量代码中看清。

延伸阅读

检查点

4个问题 · 2 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?