# PHP 语言基础

Source: https://codewiki.com/zh/php/fundamentals/

> - **what**: PHP 程序由按顺序执行的语句组成；表达式产生值，条件、循环与函数决定这些值如何参与后续执行。
> - **trap**: 自动类型转换、宽松比较以及 `and`、`or` 的低优先级会让看似直白的条件产生意外结果。
> - **fix**: 文件入口启用严格类型，比较时优先使用 `===`，复杂表达式拆成有名称的步骤，并让函数边界写明参数与返回类型。

## 是什么，为什么存在

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

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

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

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

## 工作原理

### 文件、标签与语句

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

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

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

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

### 名称、值与赋值

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

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

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

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

不会在运行期间重新绑定的名称可以声明为常量。文件或命名空间级常量可使用 `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`。两者不是风格上的同义词。

### 控制结构

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

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

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

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

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

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

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

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

### 函数边界

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

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

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

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

### 入口与失败边界

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

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

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

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

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

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

## 示例

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

### 组合表达式计算订单金额

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

<!-- quick -->

```php
// file: 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);
```

```text
subtotal=59.70
discount=10%
total=53.73
```

<!-- /quick -->

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

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

### 用控制流分类订单

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

```php
// file: 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);
}
```

```text
A-100: payment-pending
A-101: priority
A-102: ready
```

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

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

### 在循环中跳过无库存项

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

```php
// file: 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);
```

```text
BK-1: 3 ready
PN-2: skipped
NT-3: 4 ready
total: 7 ready
```

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

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

### 把配送规则收进函数

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

```php
// file: 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;
```

```text
A-103 subtotal=72.50 shipping=9.50
A-104 subtotal=120.00 shipping=0.00
```

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

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

## 陷阱

> **陷阱:** 把赋值写进条件时，拼写错误也能成为合法代码。`if ($isAdmin = true)` 会把变量改成 `true`，随后总是进入分支。

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

> **陷阱:** `and` 与 `or` 的优先级低于赋值。`$allowed = true and false` 会先把 `true` 赋给 `$allowed`，整个表达式之后才得到 `false`。

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

> **陷阱:** 用真值判断外部文本会吞掉合法的字符串 `'0'`。例如 `if (!$quantity)` 无法区分缺失、空字符串、零与布尔值 `false`。

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

> **陷阱:** `switch` 分支遗漏 `break` 会继续执行后续分支，而且 `switch` 的 case 匹配使用宽松比较。生成代码常把其他语言的 `switch` 习惯直接搬到 PHP。

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

> **陷阱:** `foreach ($items as &$item)` 结束后，`$item` 仍引用数组最后一个元素。后续复用同名变量的循环可能在没有 `&` 的情况下继续改坏最后一项。

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

<!-- deep -->

## 求值顺序与副作用

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

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

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

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

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

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

## 文件包含与作用域

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

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

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

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

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

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

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

<!-- /deep -->

[检查点: php/fundamentals](https://codewiki.com/zh/php/fundamentals/#checkpoint)

## 延伸阅读

- [PHP 手册：基本语法](https://www.php.net/manual/en/language.basic-syntax.php)
- [PHP 手册：表达式](https://www.php.net/manual/en/language.expressions.php)
- [PHP 手册：运算符优先级](https://www.php.net/manual/en/language.operators.precedence.php)
- [PHP 手册：流程控制](https://www.php.net/manual/en/control-structures.intro.php)
- [PHP 手册：用户自定义函数](https://www.php.net/manual/en/functions.user-defined.php)
