马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?站点注册
×
PowerBuilder 日期时间处理全解:Date / Time / DateTime 三类型、掩码、加减与差值(PB10 基准 · PB12.5 实测)
阅读说明
1. 适用版本:基准版本 PB10;兼容/实测:PB 12.5。本文涉及的 Date / Time / DateTime 三种类型与全部取值、格式化、加减、差值函数自 PB 早期版本就已定型,PB 6.5 / 8 / 9 / 10 / 10.5 / 11.x / 12.x 语法一致;PB 12.5 已同步实机复跑,行为与 PB 10 完全一致(见第十四节)
2. 支持数据库:SQL Server 2008 R2。仅第十一节「与数据库往返」涉及数据库,其余各节是纯语言层,不依赖任何 DBMS
3. 操作系统与环境要求:Windows XP 及以上;PB 开发环境或 PB 运行时即可,不需要任何第三方 DLL。源码须 GBK 编码 + CRLF 换行 + 无 BOM
4. 难度系数:★★★☆☆(函数本身好记,但 datetime 与 date / time 的类型边界、掩码里 m 的二义性、非法输入静默返回哨兵值这三处会反复咬人;零基础可跟做前八节)
5. 其它阅读说明:本文结论全部来自双版本实机探针(PB 10 与 PB 12.5 各自复跑,250 条语言层硬断言 + 19 条数据库断言全部命中),文中的返回值、掩码输出、进位结果、毫秒位数都是真机输出原值,不是推断值。文末附可直接导入运行的 PB10 兼容版 PBL 源码包
一、先认清三个类型:它们不是一家人
PB 里跟时间有关的类型只有三个,但它们互相不能混用,这是所有麻烦的源头。
| 类型 | 含义 | 内部精度 | 无掩码 String() 的默认样子 | | date | 只有年月日 | 天 | 2026-09-26 | | time | 只有时分秒(不含日期) | 秒(Now() 能带毫秒) | 10:20:30 | | datetime | 日期 + 时间 | 秒(Now() 能带毫秒) | 2026-09-26 10:20:30 |
三条必须记住的边界(都是实机编译出来的错误号,不是推测):
- 取值分解函数分两派,谁也不吃 datetime。Year / Month / Day / DayNumber / DayName 只收 date;Hour / Minute / Second 只收 time。把 datetime 直接喂进去,一律报 C0052 Bad argument list。要取就得先拆:Year(Date(ldt))、Hour(Time(ldt))。
- DateTime 不能直接加减数字。写 ldt = ldt + 1 报 C0011 Cannot convert。想加一天必须自己拆开再拼回去(写法见第六节)。
- date / time / datetime 转 number 是一条死路。Long(date)、Double(date)、Long(time)、Long(datetime)、Double(datetime) 全部编译通过,但运行结果恒为 0——PB 把它们当成「找不到的函数」静默处理了。反解出来的 Date(0) 是 1900-01-01、Time(0) 是 00:00:00(实测值),拿它做序列号换算会全错。
下面这段就是三派边界的实测证据,PB 10 与 PB 12.5 输出完全一致:
@@CODEBLOCK0@@
二、构造:所有合法入口,别自己发明重载
DateTime() 只有四种重载,没有「数值年月日」那种形式。
| 构造函数 | 合法重载(实测通过) | | Date | Date(yyyy, mm, dd)、Date(string)、Today() | | Time | Time(hh, mm, ss)、Time(string)、Now() | | DateTime | DateTime(date, time)、DateTime(date)、DateTime(time)、DateTime(string) |
最容易写错的一条:想「用六个数字拼一个时间点」,不能写 DateTime(2026, 9, 26, 10, 20, 30),也不能写 DateTime(2026, 9, 26) —— 两者都报 C0052 Bad argument list for function: datetime。正确写法只有一种:
- datetime ldt_a // 目标时间点
- date ld_a // 日期部分
- time lt_a // 时间部分
- // 第一步:先用数值构造 date 与 time
- ld_a = Date(2026, 9, 26)
- lt_a = Time(10, 20, 30)
- // 第二步:再用 DateTime(date, time) 合并——这是唯一的数值合并入口
- ldt_a = DateTime(ld_a, lt_a)
- MessageBox('合并结果', String(ldt_a, 'yyyy-mm-dd hh:mm:ss'))
复制代码
只给一半也可以,剩下的自动补零:
| 写法 | 实测结果 | | DateTime(ld_a) | 2026-09-26 00:00:00 | | DateTime(lt_a) | 0000-??-?? 10:20:30(日期部分是 0000-??-??,注意那两个问号,见第十四节第 15 条) | | DateTime('2026-09-26 10:20:30') | 2026-09-26 10:20:30 |
Time() 对超范围数值不报错,直接归零。 实测 Time(25, 99, 99) 得 00:00:00,Time('25:00:00') 也得 00:00:00。这两条不会抛异常,只会安静地毁掉你的数据,所以入库前一定要自己校验。
三、取值分解:八函数全谱
- date ld_a
- time lt_a
- integer li_y
- integer li_m
- integer li_d
- integer li_h
- integer li_n
- integer li_s
- ld_a = Date(2026, 9, 26)
- lt_a = Time(10, 20, 30)
- li_y = Year(ld_a) // 2026
- li_m = Month(ld_a) // 9 返回整数,不带前导零
- li_d = Day(ld_a) // 26
- li_h = Hour(lt_a) // 10
- li_n = Minute(lt_a) // 20
- li_s = Second(lt_a) // 30
- MessageBox('分解结果', String(li_y) + '-' + String(li_m) + '-' + String(li_d) + ' ' + String(li_h) + ':' + String(li_n) + ':' + String(li_s))
复制代码
DayNumber() 与 DayName() 是这一族里唯一需要专门记的:
| 函数 | 返回 | 实测值 | | DayNumber(date) | 1~7 的整数 | 周日=1、周一=2、…… 周六=7 | | DayName(date) | 英文星期名 | Monday ~ Sunday |
实测锚点(用来校验你的理解):
| 日期 | DayNumber | DayName | | 2026-09-26(周六) | 7 | Saturday | | 2026-09-27(周日) | 1 | Sunday | | 2026-09-21(周一) | 2 | Monday | | 1900-01-01 | 2 | Monday |
注意 DayNumber 的首日是周日不是周一,所以「工作日」判定要写 DayNumber(d) <> 1 and DayNumber(d) <> 7,不能写 between 1 and 5——这是本主题最高频的一个写错点。
四、String() 掩码:日期、时间、日期时间三套写法
String(value, mask) 是 PB 里唯一的格式化入口。掩码是大小写不敏感的(实测 yyyy-mm-dd 与 YYYY-MM-DD 输出相同)。
4.1 日期掩码(date 或 datetime)
| 掩码 | 实测输出 | 说明 | | yyyy-mm-dd | 2026-09-26 | 最常用 | | yyyy年mm月dd日 | 2026年09月26日 | 可以直接夹中文 | | yy | 26 | 两位年 | | yyy | 空串 | 只有 2 位和 4 位合法 | | yyyyy | 空串 | 同上 | | m/d/yy | 9/26/26 | 单 m 不带前导零 | | MM/DD/YYYY | 09/26/2026 | 大写同样识别为月/日 | | ddd | Sat | 星期简称 | | dddd | Saturday | 星期全称 | | mmm | Sep | 月份简称 | | mmmm | September | 月份全称 | | zzz-yyyy | zzz-2026 | 未被识别的字母原样输出,可当转义用 |
4.2 时间掩码(time 或 datetime)
| 掩码 | 实测输出(Time(10,20,30)) | 说明 | | hh:mm:ss | 10:20:30 | 24 小时制 | | HH:MM:SS | 10:20:30 | 大小写同样效果 | | hh时mm分ss秒 | 10时20分30秒 | 这里 mm 是分 | | h:m:s | 10:20:30 | 单字母不补零 | | h:mm:ss am/pm | 10:20:30 am | 12 小时制,小写 | | h:mm:ss AM/PM | 10:20:30 AM | 12 小时制,大写 |
12 小时制的两个端点实测(最容易被搞混的地方):
| 输入 | 掩码 | 输出 | | Time(13,20,30) | h:mm:ss am/pm | 1:20:30 pm | | Time(13,20,30) | HH:mm:ss | 13:20:30 | | Time(0,30,0) | h:mm:ss am/pm | 12:30:00 am(午夜显示 12,不是 0) | | Time(12,0,0) | h:mm:ss am/pm | 12:00:00 pm(正午显示 12) | | Time('12:00:00 AM') | HH:mm:ss | 00:00:00(字符串 AM 能正确归零) | | Time('10:20:30 PM') | HH:mm:ss | 22:20:30(字符串 PM 能正确加 12 小时) |
补零有个反直觉的坑:String(Time(5,6,7)) 不带掩码得 05:06:07(有前导零),但带上掩码 h:m:s 得 5:6:7(掩码会覆盖掉默认补零)。想要 05:06:07 就得写 hh:mm:ss。
4.3 毫秒掩码 f:Now() 才用得上
| 掩码 | 实测输出 | 说明 | | hh:mm:ss | 06:39:59 | 无小数 | | hh:mm:ss.f | 06:39:59.2 | 1 位,且不补零 | | hh:mm:ss.ff | 06:39:59.20 | 2 位 | | hh:mm:ss.fff | 06:39:59.202 | 3 位(PB 的实际精度) | | hh:mm:ss.ffffff | 06:39:59.202000 | 6 位,后面补 0 | | hh:mm:ss.fffffffff | 空串 | 9 个 f 超出上限,直接返回空串 |
还一个细节:f 不是字面量,它会被替换成毫秒数字。掩码 hh:mm:ss f 的实测输出是 06:39:59 1 —— 前面那一位 1 就是毫秒的首位,不是固定的字母 f。
Now() 的粒度约 15.6 ms:连续取 5 次 String(DateTime(Today(), Now()), 'hh:mm:ss.ffffff'),5 个结果完全相同。所以毫秒只适合做「粗排序」,不能用来做唯一键。
4.4 掩码 m 到底是月还是分:上下文判定规则
这是本节最重要的一条。m 的一对与两对,含义取决于它紧邻的邻居。实测 12 个掩码点:
| 掩码 | 实测输出 | mm 被解释为 | | mm | 09 | 月 | | m | 9 | 月 | | mmm | Sep | 月 | | MM | 09 | 月 | | yyyy-mm-dd mm | 2026-09-26 09 | 月 | | yyyy-mm-mm | 2026-09-09 | 两次都是月 | | ss:mm | 30:09 | 月 | | mm:dd | 09:26 | 月 | | dd:mm | 26:09 | 月 | | m:s | 9:30 | 月 | | mm:ss | 20:30 | 分 | | hh:mm | 10:20 | 分 | | hh:mm:ss mm | 10:20:30 09 | 第一个是分,末尾那个是月 |
归纳成一句话:只有当 mm 后面紧跟 :ss,或者前面紧跟 hh: 时,它才是「分」;其余一切情况(含 ss:mm、mm:dd、单 m、末尾 mm、MM)都是「月」。
顺带纠正一个在圈子里流传的说法:并不是 String() 会去改写实参。实测取了前后两次 Year/Month/Day/Hour/Minute/Second,六个值全部原样不变(W04 date_intact=true、W07 time_intact=true)。yyyy-mm-mm 输出 2026-09-09 纯粹是掩码被解释成「月、月」,与变量本身无关。
4.5 日期时间掩码:把两套拼起来就行
| 掩码 | 实测输出 | | yyyy-mm-dd hh:mm:ss | 2026-09-26 10:20:30 | | hh:mm:ss yyyy-mm-dd | 10:20:30 2026-09-26(顺序随便排) | | yyyy年mm月dd日 hh时mm分ss秒 | 2026年09月26日 10时20分30秒 | | ddd yyyy-mm-dd hh:mm:ss | Sat 2026-09-26 10:20:30 | | yyyy-mm-dd mm:ss | 2026-09-26 20:30 |
五、字符串转日期 / 时间:能吃什么,不能吃什么
5.1 Date() 的 15 种输入实测
| 输入 | 能否转换 | 结果 | | '2026-09-26' | 可以 | 2026-09-26 | | '2026/09/26' | 可以 | 2026-09-26 | | '2026 09 26' | 可以 | 2026-09-26 | | '2026年9月26日' | 可以 | 2026-09-26 | | '2026-9-6' | 可以 | 2026-09-06(不要求补零) | | '26-09-26' | 可以 | 2026-09-26(两位年被补成 2026) | | '2026-09-26 10:20:30' | 可以 | 2026-09-26(时间部分被忽略,不报错) | | '1899-12-31' | 可以 | 1899-12-31(1900 年之前也支持) | | '09/26/2026' | 不行 | 得 1900-01-01(美式月/日/年格式不被接受) | | '20260926' | 不行 | 得 1900-01-01(紧凑格式不被接受) | | 'abc' | 不行 | 得 1900-01-01 | | ''(空串) | 不行 | 得 1900-01-01 | | '2026-13-01' | 不行 | 得 1900-01-01(月份 13) | | '2026-09-00' | 不行 | 得 1900-01-01(日 0) | | '2026-02-30' | 不行 | 得 1900-01-01(2 月 30 日) |
5.2 非法输入不会报错,只会静默返回哨兵值 1900-01-01
这是整个主题里最危险的一条。Date('abc') 不抛异常、不返回 NULL、SQLCA.SQLCode 也不会变,它只是安静地给你一个 1900-01-01。如果你的程序把这个值写进了数据库或发给了下游系统,排查起来会非常痛苦。
所以:IsDate() / IsTime() 是唯一可靠的「字符串能否转换」判据,绝不能靠「转换后看看结果对不对」来判断。
| 校验 | 实测结果 | | IsDate('2026-09-26') | true | | IsDate('2026/09/26') | true | | IsDate('2026年9月26日') | true | | IsDate('2026-02-30') | false | | IsDate('09/26/2026') | false | | IsDate('20260926') | false | | IsDate('abc') | false | | IsDate('') | false | | IsTime('10:20:30') | true | | IsTime('10:20') | true(缺秒可以) | | IsTime('10:20:30 PM') | true(带 AM/PM 可以) | | IsTime('25:99:99') | false | | IsTime('abc') | false |
注意 IsDate('2026-09-26 10:20:30') 是 true,而 Date() 会把时间部分丢掉——校验通过 ≠ 时间信息被保留,如果要连时间一起解析,请用 DateTime(string)。
5.3 闰年规则:严格按公历,包括 1900 / 2100 这两个特例
| 表达式 | 实测结果 | 是否符合公历 | | Date(2024, 2, 29) | 2024-02-29 | 是(4 的倍数) | | Date(2000, 2, 29) | 2000-02-29 | 是(400 的倍数) | | Date(2023, 2, 29) | 1900-01-01 | 是(非闰年,判非法) | | Date(1900, 2, 29) | 1900-01-01 | 是(世纪年不闰) | | Date(2100, 2, 29) | 1900-01-01 | 是(世纪年不闰) |
1900 与 2100 都不是闰年——PB 这里处理得完全正确,没有走「能被 4 整除就算闰」的简化路线。
六、加减:RelativeDate 与 RelativeTime
6.1 天数加减:RelativeDate(date, n)
| 表达式 | 实测结果 | 考点 | | RelativeDate(Date(2026,9,26), 1) | 2026-09-27 | 普通加一天 | | RelativeDate(Date(2026,9,26), -1) | 2026-09-25 | 负数就是减 | | RelativeDate(Date(2026,9,26), 0) | 2026-09-26 | 0 原样返回 | | RelativeDate(Date(2026,1,31), 1) | 2026-02-01 | 1 月 31 日加一天自动进位到 2 月 1 日 | | RelativeDate(Date(2026,3,31), -1) | 2026-03-30 | 减一天不会退成 2 月 31 日 | | RelativeDate(Date(2024,2,28), 1) | 2024-02-29 | 闰年 2 月正确延展 | | RelativeDate(Date(2026,12,31), 1) | 2027-01-01 | 跨年正确 | | RelativeDate(Date(2026,9,26), 400) | 2027-10-31 | 大跨度也对 | | RelativeDate(Date(1900,1,1), -1) | 1899-12-31 | 1900 年之前也支持 |
它只处理日期,不碰时间。 要给一个 datetime 加一天,必须拆开再拼:
- datetime ldt_a // 原时间点
- datetime ldt_next // 加一天后的时间点
- ldt_a = DateTime(Date(2026, 9, 26), Time(10, 20, 30))
- // 先取日期部分加一天,再把时间部分原样拼回去
- ldt_next = DateTime(RelativeDate(Date(ldt_a), 1), Time(ldt_a))
- MessageBox('加一天', String(ldt_next, 'yyyy-mm-dd hh:mm:ss'))
- // 输出:2026-09-27 10:20:30
复制代码
6.2 秒数加减:RelativeTime(time, n)
| 表达式 | 实测结果 | 考点 | | RelativeTime(Time(10,20,30), 60) | 10:21:30 | 加 60 秒 | | RelativeTime(Time(10,20,30), -60) | 10:19:30 | 减 60 秒 | | RelativeTime(Time(10,20,30), 0) | 10:20:30 | 0 原样返回 | | RelativeTime(Time(23,59,30), 60) | 23:59:59 | 超出 24 点后环绕回来 | | RelativeTime(Time(23,59,59), 86400) | 23:59:59 | 加整整一天=原地不动 | | RelativeTime(Time(0,0,10), -60) | 00:00:00 | 减到负也环绕回 0 点 |
RelativeTime 是 24 小时环绕的(本质是给秒数取模),所以它既不会报错,也不会帮你跨天。要「加 3 小时并可能跨天」,正确写法是分两步:
- datetime ldt_a // 原时间点
- datetime ldt_plus // 加 3 小时后的时间点
- ldt_a = DateTime(Date(2026, 9, 26), Time(23, 0, 0))
- // 时间部分用 RelativeTime 加 3 小时;跨天的情况必须由日期部分单独处理
- ldt_plus = DateTime(Date(ldt_a), RelativeTime(Time(ldt_a), 10800))
- MessageBox('加三小时', String(ldt_plus, 'yyyy-mm-dd hh:mm:ss'))
- // 输出:2026-09-26 02:00:00 —— 注意日期没变,跨天要自己补
复制代码
上面这段实测输出 2026-09-26 02:00:00,日期停在原地,这就是「不跨天」的代价。要真正跨天,得先判断 RelativeTime 的结果是否小于原值,再决定要不要给日期加一天。这是本主题里最容易埋雷的一处,写调度类功能时务必留意。
6.3 按月加减:PB 没给,只能自己算
PB 的日期加减只有按天(RelativeDate)与按秒(RelativeTime)两种粒度,没有「按月加」。这是业务代码里出现频率极高、又最容易被写错的一个缺口——直接写 Date(Year(d), Month(d) + 1, Day(d)),遇到 1 月 31 日就会得到「2 月 31 日」这种非法日期,而 PB 会静默返回哨兵值 1900-01-01(见第 5.2 节),不报任何错。
正确做法分三步:把月份规整到 1~12 并同步年份、求出目标月的最后一天、最后把超出的日号夹到月末。
- // 按月加减:RelativeDate 只能按天加,按月必须自己算;日号超出目标月天数时夹到月末
- public function date of_addmonths (date ad_base, integer ai_months);
- integer li_y
- integer li_m
- integer li_d
- integer li_y2
- integer li_m2
- date ld_last
- date ld_res
- li_y = Year(ad_base)
- li_m = Month(ad_base) + ai_months
- li_d = Day(ad_base)
- // 第一步:把月份规整到 1~12,年份同步进退
- do while li_m > 12
- li_m = li_m - 12
- li_y = li_y + 1
- loop
- do while li_m < 1
- li_m = li_m + 12
- li_y = li_y - 1
- loop
- // 第二步:求目标月的最后一天 = 目标月的下个月 1 号往前推一天
- li_y2 = li_y
- li_m2 = li_m + 1
- if li_m2 > 12 then
- li_m2 = 1
- li_y2 = li_y2 + 1
- end if
- ld_last = RelativeDate(Date(li_y2, li_m2, 1), -1)
- // 第三步:日号超过目标月天数就夹到月末(例如 1 月 31 日加 1 个月 = 2 月 28 日)
- if li_d > Day(ld_last) then
- li_d = Day(ld_last)
- end if
- ld_res = Date(li_y, li_m, li_d)
- return ld_res
- end function
复制代码
九组边界实测(PB 10 与 PB 12.5 输出完全相同,全部进了断言):
| 起始日期 | 加减 | 结果 | 考点 | | 2026-01-31 | +1 个月 | 2026-02-28 | 日号被夹到 2 月末 | | 2026-01-31 | +3 个月 | 2026-04-30 | 4 月只有 30 天,夹到 30 | | 2024-01-31 | +1 个月 | 2024-02-29 | 闰年 2 月夹到 29 | | 2026-05-31 | +9 个月 | 2027-02-28 | 跨年 + 夹到 2 月末 | | 2026-12-15 | +1 个月 | 2027-01-15 | 正常跨年 | | 2026-02-28 | +12 个月 | 2027-02-28 | 正好一年 | | 2026-01-15 | -1 个月 | 2025-12-15 | 反向跨年 | | 2026-03-31 | -1 个月 | 2026-02-28 | 反向也要夹月末 | | 2026-09-26 | 0 个月 | 2026-09-26 | 零月原样返回 |
如果不希望「夹到月末」、而是希望「溢出后进到次月」(即 1 月 31 日 + 1 个月 = 3 月 3 日),把第三步整个删掉、改成给 ld_last 续加剩余天数即可——但绝大多数排期、账单、续费场景要的是夹到月末,请按业务语义明确选择,不要留下两种解释都通的代码。
七、差值:DaysAfter 与 SecondsAfter
| 表达式 | 实测结果 | 说明 | | DaysAfter(Date(2026,9,26), Date(2026,9,26)) | 0 | 同一天为 0 | | DaysAfter(Date(2026,1,1), Date(2026,12,31)) | 364 | 2026 全年(平年 365 天,差 364) | | DaysAfter(Date(2026,12,31), Date(2026,1,1)) | -364 | 反序为负数 | | DaysAfter(Date(1900,1,1), Date(1900,3,1)) | 59 | 1900 非闰年 | | DaysAfter(Date(2000,1,1), Date(2000,3,1)) | 60 | 2000 是闰年 | | DaysAfter(Date(2024,1,1), Date(2024,3,1)) | 60 | 2024 是闰年 | | DaysAfter(Date(2023,1,1), Date(2023,3,1)) | 59 | 2023 非闰年 | | SecondsAfter(Time(0,0,0), Time(1,1,1)) | 3661 | 1 小时 1 分 1 秒 | | SecondsAfter(Time(1,1,1), Time(0,0,0)) | -3661 | 反序为负 | | SecondsAfter(Time(23,0,0), Time(1,0,0)) | -79200 | 不跨天环绕,就是 1 点减 23 点 | | SecondsAfter(Time(10,0,0), Time(10,0,0)) | 0 | 同值为 0 |
DaysAfter 是纯自然日差,不受时分秒影响。SecondsAfter 是同一天内的秒差——它不会把 23:00 → 01:00 理解成「跨过零点后的 2 小时」,而是老老实实算成 -79200。要算跨零点的时长,请自己补 +86400。
当年/月/日的先后判断,PB 允许直接用比较运算符,实测 Date、Time、DateTime 三种类型都支持 < > <= >= = <>:
- boolean lb_ok // 比较结果(注意 PB 的布尔类型叫 boolean,不是 logical)
- date ld_a
- date ld_b
- time lt_a
- time lt_b
- ld_a = Date(2026, 1, 1)
- ld_b = Date(2025, 12, 31)
- lt_a = Time(1, 0, 0)
- lt_b = Time(0, 59, 59)
- lb_ok = ld_a > ld_b
- // 输出 true;同样写法对 time 与 datetime 一样成立
- MessageBox('比较结果', String(lb_ok) + ' / ' + String(lt_a > lt_b))
复制代码
八、NULL 会传染:三态必须显式处理
PB 的 date / time 变量声明后不是 NULL,是 1900-01-01 / 00:00:00。一旦被显式 SetNull,麻烦就来了——NULL 会顺着表达式传染到结果上:
| 表达式 | 实测结果 | | IsNull(ld_n)(已 SetNull) | true | | IsNull(String(ld_n)) | true(String(NULL) 也是 NULL,不是空串) | | IsNull(DaysAfter(ld_n, Date(2026,1,1))) | true | | IsNull(RelativeDate(ld_n, 1)) | true | | IsNull(RelativeTime(lt_n, 60)) | true | | IsNull(IsDate(ls_null)) | true ← IsDate(NULL) 返回 NULL,不是 false |
最后一条最容易踩:if IsDate(ls_x) then ... 在 ls_x 为 NULL 时既不走 then 也不走 else 的正常分支(NULL 在条件判断里当假处理,但语义上和 false 不是一回事)。稳妥写法是两段判:
- string ls_in // 待校验的日期字符串(示例输入)
- date ld_out // 转换结果
- boolean lb_bad // 是否非法
- ls_in = '2026-09-26'
- lb_bad = false
- // 第一段:先挡 NULL;第二段:再用 IsDate 挡非法格式
- if IsNull(ls_in) then
- lb_bad = true
- elseif not IsDate(ls_in) then
- lb_bad = true
- end if
- if lb_bad then
- MessageBox('结果', '输入非法,已拒绝')
- else
- ld_out = Date(ls_in)
- MessageBox('结果', String(ld_out, 'yyyy-mm-dd dddd'))
- end if
复制代码
九、当前时间与计时:Today / Now / CPU / GetTickCount
| 函数 | 返回 | 实测要点 | | Today() | date | 当天日期 | | Now() | time | 当前时间,自带毫秒 | | DateTime(Today(), Now()) | datetime | 拿完整时间戳的标准写法 | | CPU() | double | 见下方警告 | | GetTickCount() | ulong | 毫秒计数,需在 type prototypes 里声明 |
「一行拿到完整时间戳」只有一种写法:Now() 只返回 time,不会给你日期。
- // 一行拿到「现在」的完整时间戳:Now() 只给时间,必须与 Today() 拼
- string ls_stamp // 结果字符串
- ls_stamp = String(DateTime(Today(), Now()), 'yyyy-mm-dd hh:mm:ss')
- MessageBox('当前时间戳', ls_stamp)
复制代码
GetTickCount() 是 Windows API,要在对象的 type prototypes 段里先声明才能用(不声明会报 C0051 Unknown function name):
- type prototypes
- public:
- function ulong GetTickCount() library "kernel32.dll"
- end prototypes
复制代码- ulong lu_t1 // 起始毫秒
- ulong lu_t2 // 结束毫秒
- long ll_j // 循环计数
- long ll_n // 累加器
- lu_t1 = GetTickCount()
- ll_n = 0
- for ll_j = 1 to 8000000
- ll_n = ll_n + 1
- next
- lu_t2 = GetTickCount()
- MessageBox('耗时(毫秒)', String(lu_t2 - lu_t1) + ' / 循环 ' + String(ll_n) + ' 次')
- // 实测约 625 毫秒
复制代码
关于 CPU() 的一条重要实测结论:不少资料说 CPU() 返回的是「应用消耗的 CPU 时间(秒)」。实测不支持这个说法——
| 场景 | GetTickCount() 增量 | CPU() 增量 | | Sleep(2)(睡 2 秒,几乎不占 CPU) | 2015 | 2015.000000 | | 800 万次空循环(占满一个核) | 625 | 625.000000 |
Sleep(2) 期间进程几乎不消耗 CPU,但 CPU() 照样走了 2015;而两次增量与 GetTickCount() 数值完全相等。结论:实测 CPU() 是与 GetTickCount() 同量纲的墙钟毫秒计时器(至少在本机 PB 10 与 PB 12.5 上是如此),既不是「秒」也不是「CPU 占用时间」。要给代码计时,请统一用 GetTickCount(),别依赖 CPU()。
十、完整示例:nvo_dt_demo 交期计算器
前面的碎片拼起来就是一个真能用的东西:给一个开始日期和工期天数,推算出交期(跳过周六周日)。
前置:新建一个 PBL,并新建一个定制类用户对象(Custom Class),命名 nvo_dt_demo,祖先保持 nonvisualobject。
步骤:
1. 把下面整段源码粘进 nvo_dt_demo(或直接导入随文附的 .sru),全量重建(Rebuild);
2. 新建一个窗口 w_dt_demo,在窗口上放一个按钮 cb_run;
3. 在窗口的实例变量里加一行 nvo_dt_demo iuo_dt;
4. 在 cb_run 的 clicked 事件里贴入第二段代码;
5. 运行窗口并点击按钮,即可在弹窗里看到推算结果。 - // ===================== nvo_dt_demo 用户对象源码 =====================
- forward prototypes
- public function date of_addworkdays (date ad_start, integer ai_days)
- public function date of_addmonths (date ad_base, integer ai_months)
- public function string of_plan (string as_start, integer ai_days)
- public function string of_month (string as_start, integer ai_months)
- public function string of_stamp ()
- public function string of_demo (string as_start, integer ai_days)
- end prototypes
- // 在一段日期上往后推 ai_days 个「工作日」(跳过周六周日),返回落点日期
- public function date of_addworkdays (date ad_start, integer ai_days);
- long ll_added
- date ld_cur
- ll_added = 0
- ld_cur = ad_start
- do while ll_added < ai_days
- // 每次都先往后推 1 个自然日,再判断是不是工作日
- ld_cur = RelativeDate(ld_cur, 1)
- // DayNumber:周日=1、周六=7,只有 2~6 才是工作日
- if DayNumber(ld_cur) <> 1 and DayNumber(ld_cur) <> 7 then
- ll_added = ll_added + 1
- end if
- loop
- return ld_cur
- end function
- // 按月加减:RelativeDate 只能按天加,按月必须自己算;日号超出目标月天数时夹到月末
- public function date of_addmonths (date ad_base, integer ai_months);
- integer li_y
- integer li_m
- integer li_d
- integer li_y2
- integer li_m2
- date ld_last
- date ld_res
- li_y = Year(ad_base)
- li_m = Month(ad_base) + ai_months
- li_d = Day(ad_base)
- // 第一步:把月份规整到 1~12,年份同步进退
- do while li_m > 12
- li_m = li_m - 12
- li_y = li_y + 1
- loop
- do while li_m < 1
- li_m = li_m + 12
- li_y = li_y - 1
- loop
- // 第二步:求目标月的最后一天 = 目标月的下个月 1 号往前推一天
- li_y2 = li_y
- li_m2 = li_m + 1
- if li_m2 > 12 then
- li_m2 = 1
- li_y2 = li_y2 + 1
- end if
- ld_last = RelativeDate(Date(li_y2, li_m2, 1), -1)
- // 第三步:日号超过目标月天数就夹到月末(例如 1 月 31 日加 1 个月 = 2 月 28 日)
- if li_d > Day(ld_last) then
- li_d = Day(ld_last)
- end if
- ld_res = Date(li_y, li_m, li_d)
- return ld_res
- end function
- // 生成一句可读的排期说明;开始日期非法或工时<=0 时直接返回错误提示
- public function string of_plan (string as_start, integer ai_days);
- date ld_start
- date ld_due
- long ll_gap
- string ls_out
- // IsDate 是唯一可靠的「字符串能否转日期」判据,不能靠 Date() 的返回值判断
- if not IsDate(as_start) then
- return '开始日期不合法:' + as_start
- end if
- ld_start = Date(as_start)
- if ai_days <= 0 then
- return '工时不合法:' + String(ai_days)
- end if
- ld_due = of_addworkdays(ld_start, ai_days)
- ll_gap = DaysAfter(ld_start, ld_due)
- ls_out = '开始 ' + String(ld_start, 'yyyy-mm-dd ddd')
- ls_out = ls_out + '|工作日 ' + String(ai_days) + ' 天'
- ls_out = ls_out + '|交期 ' + String(ld_due, 'yyyy-mm-dd dddd')
- ls_out = ls_out + '|含周末共 ' + String(ll_gap) + ' 个自然日'
- return ls_out
- end function
- // 按月加减的可读输出
- public function string of_month (string as_start, integer ai_months);
- date ld_start
- date ld_due
- string ls_out
- if not IsDate(as_start) then
- return '开始日期不合法:' + as_start
- end if
- ld_start = Date(as_start)
- ld_due = of_addmonths(ld_start, ai_months)
- ls_out = String(ld_start, 'yyyy-mm-dd ddd')
- ls_out = ls_out + ' 加减 ' + String(ai_months) + ' 个月 = '
- ls_out = ls_out + String(ld_due, 'yyyy-mm-dd ddd')
- return ls_out
- end function
- // 一行拿到「现在」的完整时间戳:Now() 只返回 Time,必须与 Today() 拼
- public function string of_stamp ();
- return String(DateTime(Today(), Now()), 'yyyy-mm-dd hh:mm:ss')
- end function
- // 演示入口:把结果拼成一段可直接 MessageBox 的文本
- public function string of_demo (string as_start, integer ai_days);
- return of_plan(as_start, ai_days) + '~r~n时间戳 ' + of_stamp()
- end function
复制代码- // ===================== 窗口按钮 cb_run 的 clicked 事件 =====================
- // 示例输入:计划开始日期(字符串形式,模拟从界面或文件读到的值)
- string ls_start
- // 示例输入:需要的工时天数
- integer li_days
- string ls_out
- ls_start = '2026-09-26'
- li_days = 5
- iuo_dt = create nvo_dt_demo
- ls_out = iuo_dt.of_demo(ls_start, li_days)
- MessageBox('交期推算', ls_out)
复制代码
实机运行输出的原值(PB 10 与 PB 12.5 逐字符相同):
- 开始 2026-09-26 Sat|工作日 5 天|交期 2026-10-02 Friday|含周末共 6 个自然日
- 时间戳 2026-09-26 07:13:25
- 工时不合法:0
- 开始日期不合法:abc
- 开始 2026-01-30 Fri|工作日 3 天|交期 2026-02-04 Wednesday|含周末共 5 个自然日
- 开始 2026-12-30 Wed|工作日 3 天|交期 2027-01-04 Monday|含周末共 5 个自然日
- 开始 2024-02-28 Wed|工作日 2 天|交期 2024-03-01 Friday|含周末共 2 个自然日
- 开始 2026-09-21 Mon|工作日 5 天|交期 2026-09-28 Monday|含周末共 7 个自然日
- stamp_len=19
- 2026-01-31 Sat 加减 1 个月 = 2026-02-28 Sat
- 2026-01-31 Sat 加减 3 个月 = 2026-04-30 Thu
- 2024-01-31 Wed 加减 1 个月 = 2024-02-29 Thu
- 2026-12-15 Tue 加减 1 个月 = 2027-01-15 Fri
- 2026-01-15 Thu 加减 -1 个月 = 2025-12-15 Mon
- 2026-03-31 Tue 加减 -1 个月 = 2026-02-28 Sat
- 2026-05-31 Sun 加减 9 个月 = 2027-02-28 Sun
- 2026-02-28 Sat 加减 12 个月 = 2027-02-28 Sun
- 2026-09-26 Sat 加减 0 个月 = 2026-09-26 Sat
复制代码
几个可以对着核对的点:9 月 26 日是周六,往后数 5 个工作日(跳过 27 日周日)落在 10 月 2 日周五,中间跨过 6 个自然日;跨年、跨闰年 2 月、跨月的四种边界也都正确。stamp_len=19 说明时间戳固定 19 个字符(yyyy-mm-dd hh:mm:ss),可直接当定长字段用。
十一、与数据库往返:SQL Server 2008 R2 实测
这一节用 PB 的嵌入式 SQL 直接往 SQL Server 2008 R2 写时间,再读回来对比。测试表(临时表,会话结束自动消失):
- CREATE TABLE #dt48 (
- id int identity,
- d datetime,
- sd smalldatetime,
- d2 datetime2(7),
- dv varchar(30)
- )
复制代码
第一组:变量绑定往返(PB 的 datetime 变量直接绑到列)。 这一组连跑四次,读数如下:
| 场景 | 写入值 | 读回值 | 结论 | | 变量绑 datetime(无毫秒) | 2026-09-26 10:20:30 | 2026-09-26 10:20:30.000000 | 无损 | | 变量绑 datetime(带毫秒) | ... 07:10:35.503 | ... 07:10:35.503 | 这一次恰好落在栅格上,看不出差别 | | 变量绑 datetime(带毫秒) | ... 07:18:44.822 | ... 07:18:44.823 | 被栅格向上舍入 1 ms | | 变量绑 datetime(带毫秒) | ... 07:19:07.414 | ... 07:19:07.413 | 被栅格向下舍入 1 ms | | 变量绑 datetime2(7) | ... 07:18:44.822、... 07:19:07.414 | 与写入值逐位相同 | 毫秒完全无损,四次全部一致 |
结论很明确:PB 的 datetime 变量本身能带毫秒,但存进 SQL Server 的 datetime 列之后会被 3.33 ms 栅格改变(往上或往下 1 ms 都可能,取决于值落在栅格哪一侧);要精确保存毫秒,必须用 datetime2(7)——实测四次往返全部逐位相同,SQLCode 也一直是 0。
这也解释了很多人踩过的一个坑:同一段代码有时候毫秒对得上、有时候差 1 毫秒,看起来像「偶发 bug」,其实是 datetime 列的栅格在挑值,不是你的代码不稳定。
第二组:smalldatetime 的分钟进位规则。 smalldatetime 的精度只到分钟,秒会被舍入:
| 写入秒数 | 读回值 | 规则 | | 10:20:29 | 2026-09-26 10:20:00 | 秒 < 30,向下舍到本分钟 | | 10:20:30 | 2026-09-26 10:21:00 | 秒 = 30,进位到下一分钟 | | 10:20:31 | 2026-09-26 10:21:00 | 秒 > 30,进位 | | 10:20:59 | 2026-09-26 10:21:00 | 进位 |
四条合起来说明:smalldatetime 的秒字段取值永远是 00,阈值是「≥30 进位、<30 舍去」。拿它存「当天几点几分」没问题,存带秒的时刻一定会被改。
第三组:datetime 的毫秒会按 3.33 ms 栅格对齐。
| 写入 | 读回 | 说明 | | 10:20:30.123 | 10:20:30.123 | 正好落在栅格上 | | 10:20:30.124 | 10:20:30.123 | 向下舍到栅格 | | 10:20:30.126 | 10:20:30.127 | 向上进到栅格 |
所以 datetime 的毫秒是「约值」不是「精确值」,期望 .124 原样存原样取是不现实的。要精确到毫秒,用 datetime2(7)(实测 PB 变量可以直接绑,读写都正常,且第一组已验证逐位无损)。
第四组:用字符串写法可以精确写入毫秒。 把值拼成 '2026-09-26 10:20:30.123' 再 UPDATE,读回是 10:20:30.123000——三位毫秒一位不差。需要精确保存时,这条路比变量绑定更可控。
十二、与 PBIDEA uo_datetime 怎么选
PBIDEA 提供了一个面向对象的 uo_datetime:一个对象持有一个时间点,Set / format / add / truncate / diff / timestamp / MLastDay / SetGMT 等方法一应俱全。两者不是替代关系,选型建议如下:
| 需求 | 用原生 Date / Time / DateTime | 用 uo_datetime | | 取个今天、算个差值、格式化成字符串 | 首选,零依赖、几行搞定 | 没必要,多一层对象 | | 大量时间点比较、排序、存放 | 首选,原生类型能直接进 DataWindow | 对象要自己拆字段 | | 复杂的 {} 格式语法、按月加减、取当月最后一天 | 「按月加」要自己写(见第 6.3 节)、「当月最后一天」可以用 RelativeDate(Date(y, m+1, 1), -1) | 首选,MLastDay / truncate 直接给结果 | | 时区显示切换 | 不支持 | 有 SetGMT(注意它会全局生效) | | 交付给只装了 PB、没装 PBIDEA 的环境 | 首选 | 需要一并交付 PBIDEA 依赖 |
一句话:语言层能解决的用原生三类型;只有涉及「按月加减」「当月最后一天」「格式化模板」「时区」时才值得引入 uo_datetime。
十三、坑清单:22 条一表带走
| # | 坑 | 正确做法 | | 1 | DateTime(2026, 9, 26) 报 C0052 | DateTime() 没有数值重载,用 DateTime(Date(...), Time(...)) | | 2 | DateTime(2026,9,26,10,20,30) 报 C0052 | 同上,先用 Date() / Time() 构造再合并 | | 3 | ldt = ldt + 1 报 C0011 | 拆成 DateTime(RelativeDate(Date(ldt),1), Time(ldt)) | | 4 | Year(ldt) / Month(ldt) / DayNumber(ldt) 报 C0052 | date 类函数只吃 date,写 Year(Date(ldt)) | | 5 | Hour(ldt) / Minute(ldt) / Second(ldt) 报 C0052 | time 类函数只吃 time,写 Hour(Time(ldt)) | | 6 | RelativeDate(ldt,1) / IsDate(ldt) / DaysAfter(d, ldt) 报 C0052 | 先 Date(ldt) 转换 | | 7 | Long(date) / Double(time) 等编译通过但恒为 0 | 别拿数值反解日期,用 DaysAfter 算天数差 | | 8 | Date('2026-02-30') 不报错,静默给 1900-01-01 | 转换前必须 IsDate() | | 9 | IsDate('09/26/2026') = false、IsDate('20260926') = false | 输入统一成 yyyy-mm-dd 或 yyyy/mm/dd | | 10 | IsDate(NULL) 返回 NULL 而非 false | if not IsNull(x) and IsDate(x) then 两段判 | | 11 | String(Time(5,6,7)) 有前导零,加掩码后不补零 | 要 05:06:07 就显式写 hh:mm:ss | | 12 | 掩码 mm 在 ss:mm / mm:dd 里被当成「月」 | 只有 mm:ss、hh:mm 里 mm 才是「分」 | | 13 | 掩码 yyy / yyyyy 返回空串 | 年份只写 yy 或 yyyy | | 14 | 掩码 fffffffff 返回空串 | f 最多写到 6 位 | | 15 | String(DateTime(Time(10,20,30))) 得 0000-??-?? 10:20:30 | 别把只带时间的 datetime 直接进正文/存档 | | 16 | RelativeTime(Time(23,59,30), 60) 得 23:59:59(不跨天) | 跨天要自己给日期加一天 | | 17 | SecondsAfter(Time(23,0,0), Time(1,0,0)) 得 -79200 | 跨零点时长自己 + 86400 | | 18 | Time(25, 99, 99) 不报错,返回 00:00:00 | 入库前先校验时分秒范围 | | 19 | 写 logical 报 C0001 Illegal data type | PB 的布尔类型叫 boolean,没有 logical | | 20 | CPU() 被当成「CPU 时间(秒)」用 | 实测是毫秒级墙钟,计时统一用 GetTickCount() | | 21 | 想「按月加」却去用 RelativeDate(它只按天) | 自己写 of_addmonths,并把超出的日号夹到月末(见第 6.3 节) | | 22 | 用 datetime 列存毫秒,发现「有时对有时差 1 毫秒」 | 那是 3.33 ms 栅格在舍入,改用 datetime2(7) 可逐位无损(见第十一节) |
十四、PB12.5 差异
没有差异。 逐项核对口径如下:
- 六个探针对象(nvo_dt_probe ~ nvo_dt_probe5 与 nvo_dt_demo)在 PB 10 与 PB 12.5 下各自导入,导入错误均为 0;
- 全量重建(Rebuild all)两版均 0 错误 0 警告;
- 六个 .pbtest 断言套件两版全部 PASS,语言层 250 条硬断言全部命中;
- 两版诊断输出留档逐行比对:排除含当前时刻的行与含计时的行之后,差异行数为 0(A/B/C/D/E/F 六组全部为 0);
- 四条故意写错的负向源码({d '...'} 日期字面量、date + 1、把 Hour() 传给 DateTime、函数直接返回数组)在两版下都按预期导入失败,错误号也一致。
唯一需要注意的不是版本差异,而是环境差异:PB 10 的运行包里不含 SQL Server 的 SNC 接口 DLL,所以第十一节的数据库往返测试在 PB 10 侧会连不上(SQLCode = -1),该用例已按设计整体跳过、不计失败;数据库往返部分的结论全部来自 PB 12.5 + SQL Server 2008 R2 实机。
十五、实机验证情况
| 项目 | PB 10(基准) | PB 12.5(兼容/实测) | | 源码导入(6 个 .sru) | 0 错误 | 0 错误 | | 全量重建 --type full | 0 错误 0 警告 | 0 错误 0 警告 | | PBVM 运行断言(6 个 .pbtest) | 全 PASS,250 条硬断言全中 | 全 PASS,250 条硬断言全中 | | 负向源码(4 个) | 均按预期失败 | 均按预期失败 | | 数据库往返(SQL Server 2008 R2) | 运行包无 SNC 接口,按设计跳过 | 全 PASS,19 条断言全中 | | 源文件编码检查 | GBK / CRLF / 无 BOM 全部 OK | 同左 | | 静态检查(原型与实现配对、内置函数名校验) | 0 错 0 提醒 | 0 错 0 提醒 | | 两版输出比对 | 排除时刻与计时行后逐行差异为 0 | 同左 |
所有示例代码均为 PB10 语法基准,未使用 PB 11 及以后才有的特性;PB 12.5 侧为实机复跑验证,结论与 PB 10 一致。
文末附可直接导入运行的 PB10 兼容版 PBL 源码包,内含示例用户对象 nvo_dt_demo、五个探针对象与全部断言脚本,导入后重建即可复现本文所有读数。 |