
如果你是从 Python 或 C 切到 Julia 的人第一次读到 Julia 基本运算符的时候大概率会经历一段“这不是我认识的那个符号”的恍惚期。同一个%在 Julia 里语义更接近 C 语言的rem而不是 Python 的取模同一个在 Python 里能拼字符串在 Julia 里会直接给你抛一个MethodError同一个^在 C 里是按位异或在 Julia 里才是真正的幂。这篇文章我想把 Julia 的运算符完整串一遍重点不是列文档而是把每个符号背后的设计逻辑、容易踩的坑、以及我在实际调程序时总结的排查方法讲清楚。刚接触 Julia 的读者可以从头读已经有基础的可以直接跳到第 5 章看速查表和避坑清单。1. 算术运算符先说三个最容易误解的符号1.1 除法三兄弟/、÷、// 到底怎么选在大多数 C 系语言里整数除以整数的结果也是整数9 / 2等于 4很多从 C 转过来的朋友一开始写 Julia 也习惯性以为会得到 4。Julia 的第一个反直觉设计就是这个/永远返回浮点数9 / 2是4.5如果你想要整数结果就用9 ÷ 2或者调用div(9, 2)如果你想保留精确的有理数9 // 2会给你一个Rational{Int64}类型的9//2。三者的分工其实是 Julia 对数学语义的一次明确切割普通除法、整除、有理数构造共用同一个数学符号家族但各有各的用途。这一点在数值计算里非常重要因为返回类型决定后续链条的行为9/2如果参与数组构造结果直接变成浮点数组若不注意会让整个函数类型不稳定性能随之崩掉。÷在 REPL 里输入\div再按 Tab 就能打出来也可以用div(a, b)函数形式。更细一点Julia 还提供了三个“方向不同”的整除版本div是向零截断fld是向负无穷方向取整cld是向正无穷方向取整。它们处理负数时的差异非常明显div(-7, 2)得到-3而fld(-7, 2)得到-4cld(-7, 2)得到-3。处理数组分块、分页这类需求时fld和cld省掉了你自己写偏移量的功夫这是很多刚转过来的人不知道的小宝藏。除法家族里还有一个容易被忽略的反斜杠\它叫左除b \ a等价于a / b。在矩阵场景里A \ b是解线性方程组A*x b的标准入口我在后面讲实用技巧时还会用它但在基本运算符阶段你只需要知道它的存在别把它当成注释符号就行。一个我一直推荐的练习是打开 REPL把9 / 2、9 ÷ 2、9 // 2、-7 ÷ 2、fld(-7, 2)、cld(-7, 2)全部敲一遍对比输出。做一遍比读十遍文档有效。1.2 取余取模% 不是 Python 的 %mod 才是如果说除法三兄弟是第一张惊讶脸那么%运算符就是 Julia 新手最常翻车的地方。在 Julia 里%等价于rem它按被除数的符号决定结果的符号而 Python 的%是真正意义上的取模结果符号跟随除数。举个例子-7 % 3在 Julia 里是-1在 Python 里是2。为什么会有这种差异因为 C 系语言里%是 remainder余数语义Python 吸收的是数学里模运算的语义。Julia 选择把%指向rem同时又单独提供一个mod函数来满足取模需求。表达式Julia 结果Python 结果语义7 % 311两者相同-7 % 3-12Julia 是 remPython 是 mod7 % -31-2符号规则再次分叉mod(-7, 3)22显式取模rem(-7, 3)-1-1显式取余什么时候必须留意最典型的是循环索引和哈希散列比如你需要一个“总是回到区间 [0, n) 的下标”这属于取模语义应写mod(i, n)而不是i % n。我自己就栽过写数论算法时用-7 % 3判断整除关系结果划分到负数区间整个测试挂了半小时。后来我给自己定了一条规矩涉及负数、循环、散列时一律显式写mod或rem绝不依赖%的默认行为。还有一个细节%作用于浮点数也有定义比如5.5 % 2.0得到1.5这在处理周期信号时很顺手。但要注意浮点取余同样受符号规则影响别在正负边界上默认编译器替你做了正确的事。1.3 幂、负号与字符串乘法三个违反直觉但符合数学直觉的设计幂运算符^在 Julia 里是真正的数学幂运算而且它有两个容易出错的优先级特点一是^的优先级高于一元负号所以-2^2等于-4而不是4二是^是右结合的2^3^2等于2^(3^2)即512而不是(2^3)^2。这两个点都符合数学书写习惯但如果你是从 C 或某些脚本语言转过来第一眼很容易觉得“这有问题”。实际上这才是对的数学里指数先算、负号后加-x^2本意就是-(x^2)。让我直接给出可复现的例子julia -2^2 -4 julia 2^3^2 512 julia 2.0^0.5 1.4142135623730951第一条输出 -4第二条 512如果你理解的优先级是“从左到右”第十次读文档可能还是会被坑。我的建议很简单幂运算外面如果涉及负号永远写-(2^2)如果涉及连续幂永远写2^(3^2)。括号不丢人丢人的是凌晨三点调不出正确结果的自己。字符串乘法是另一个高频惊讶点Julia 里字符串拼接用*不是。所以hello world会报MethodError正确写法是hello * world字符串重复用幂ab^3得到ababab。从语义上看这其实是把字符串当成了某种“乘法半群”拼接对应乘法、重复对应幂。对写科学计算的人来说这个设计的代价是初期不适收益是永远只做数值和矩阵加法不会出现“字符串也能相加”这种隐式转换灾难。这也是 Julia 一贯的风格类型精确、行为可预测。2. 赋值、比较与逻辑返回值是藏得最深的坑2.1 赋值运算符家族、、. 的返回值与内存行为赋值运算符不用多说真正值得强调的是一组“更新运算符”、-、*、÷等。它们的语义看起来只是简写但有一个非常容易踩的细节Julia 中这类复合赋值表达式的求值结果是nothing。什么意思b a 1不会把b设为a1而是把它设为nothing。我见过不止一个新同事在这种地方 debug 半天最后发现变量变成了nothing往下一层传参直接崩。这个设计是有意为之更新运算符关注的是副作用不鼓励你把它当作有返回值的表达式继续组合。Julia 还有一个专门针对数组的广播赋值.它的语义完全不同。A . B表示“把 B 的每个元素写入 A 的对应位置”是原地更新不会让A指向新对象而A A . 1会生成一个新数组并重新绑定变量名。用.这类原地赋值是 Julia 写高性能循环时非常重要的一招因为它能避免分配临时数组、减少内存压力。我给一个最简单的内存对比思路A [1, 2, 3] # 原地更新A 地址不变 A . A . 1 # 重新绑定A 变成新数组 A A . 1第二种写法每次循环都会产生一个临时数组。问题不在写法“错了”而在你不知道它分配了内存等到数据量上了千万级这种隐式分配就是性能杀手。关于这一点我会在第 4 章讲广播时再展开。2.2 比较运算符、isequal、 三者的差异比较运算符家族里有三兄弟特别容易混淆、isequal、。是数值语义比较所以1 1.0返回true是对象同一性比较编译期就能判定1 1.0返回false。对于数组这种可变类型[1,2] [1,2]返回true但[1,2] [1,2]返回false因为两个数组对象本身不同。第三个isequal是 Julia 为哈希和特殊值设计的全序比较工具它能处理NaN和-0.0这两个搞不定的边界NaN NaN是false但isequal(NaN, NaN)是true-0.0 0.0是true但isequal(-0.0, 0.0)是false。比较表达式isequal1vs1.0truefalse? 实际这里要看实现细节falseNaNvsNaNfalsetruefalse对可变对象才是同一性重点-0.0vs0.0truefalsefalse[1,2]vs[1,2]truetruefalse为什么NaN NaN会是false这是 IEEE 754 的标准行为因为在数学上 NaN 不是一个确定的数它不代表任何具体值。但你在做数据去重、哈希键查找时往往希望“同一个 NaN 应该互相同一”所以isequal专门处理这类语义。日常对比变量用判断两个对象是否真的是同一个引用用处理 NaN 相关逻辑用isequal这是我总结的口诀。比较运算符还有一个 Julia 特色链式比较。0 x 10在 Julia 里是合法表达式它等价于0 x x 10而且x只求值一次。你可以放心写1 ≤ a b ≤ 100这种连续判断可读性比嵌套高得多。这个能力是少数几种语言才有的用熟了以后回写 C 会很不习惯。2.3 逻辑运算符与三元短路返回操作数本身和||在 Julia 里不只是返回布尔值的地方它们的规则更灵活a b会先求值a如果a是假值就直接返回a否则返回b的求值结果a || b同理如果a是真值就直接返回a否则返回b。所以true 42返回420 42返回0false || error(boom)会真的抛出异常。这个“返回最后一个求值表达式”的行为和 Python 很像而且意味着你可以用它写出很紧凑的条件执行isodd(7) println(odd)这和if语句一样安全因为有短路特性。反过来如果你需要的是“两个整数按位与”那得用需要“按位或”用|。很多从 C 来的人容易把和混用在 Julia 里是按位运算按位运算的结果不一定是布尔值比如5 3得1也没法直接当条件判断用虽然非零为真但表达式类型上仍是整数。至于一元逻辑非Julia 用的是!!true是false。这里要特别注意按位取非要写~x不要写!x~0在 Int64 下是-1而!0会直接报TypeError。三元运算符条件 ? 真值表达式 : 假值表达式也是常用表达式注意它的优先级很低低于||但高于赋值。复杂链式三元a ? b : c ? d : e在 Julia 里是合法的但对读者很不友好我的建议是超过两层就改回if/elseif/else。3. 位运算、优先级与数值溢出性能优化绕不开的三件事3.1 位运算家族 与 的语义差异如果你写的是数值算法、状态压缩或底层优化位运算是绕不开的一类运算符。Julia 提供了完整的位运算家族按位与、|按位或、⊻按位异或、~按位取反以及三组移位运算符、、。左移很好理解x 1相当于乘 2不溢出时是算术右移它保留符号位负数右移后仍然是负数方向是逻辑右移高位一律补 0只适合无符号语义。示例julia -7 1 -4 julia -7 1 9223372036854775804第一条结果是-4因为-7二进制补码表示的算术右移会让高位补符号位第二条结果是 Int64 下逻辑右移得到的巨大正数。刚转过来的人常在和之间踩坑尤其是做自定义哈希、位图操作时一旦把无符号数据用算术右移处理符号位会参与补位结果直接错位。位运算还有一些实用技巧。判断整数奇偶可以用x 1 0乘 2 的幂可以用x n取模 2 的幂可以用x (n - 1)仅对正数且n为 2 的幂成立。这些写进热循环里确实能省下一点点时间但在现代 Julia 里编译器已经很聪明很多优化它自己能做我更推荐优先保证可读性只有 profiling 指出瓶颈时才考虑位运算替换。3.2 优先级速查表把常见语言和 Julia 的差异圈出来运算符优先级这话题很烦人但它是所有“为什么结果不是我想要的”问题的根源。这里给一张 Julia 常用优先级速查表从上到下表示优先级从高到低优先级运算符类别高调用/下标f()、a[i]↓转置A↓幂^右结合↓一元运算-、、!、~↓乘除类*、/、÷、%、\↓加减类、-↓移位、、↓按位与↓按位异或⊻↓按位或↓比较、!、、、、、等↓逻辑与↓逻辑或↓三元? :低赋值/更新、等这张表里有几个容易记错的点。第一^高于一元负号-2^2是-4第二位运算优先级高于比较运算所以x 1 0在 Julia 里实际解析为(x 1) 0但在 C 语言里会解析成x (1 0)同一行代码两种语言两种结果第三高于||但也别因此把a || b c这种表达式留给队友去猜。既然优先级这么容易出现跨语言差异我在工程里的习惯是除了*和/这种绝对安全的组合其余只要涉及位运算、逻辑运算、比较运算混排一律加括号。性能上没有任何损失可读性和正确性却直接拉满。3.3 溢出与类型提升运算符背后的“隐藏机制”Julia 的默认整数是平台相关的 Int64它不是 Python 那种无限精度整数。typemax(Int64)是 9223372036854775807你再 1结果是 -9223372036854775808直接回绕到最小负数。这个行为是底层 LLVM 算术指令的直接映射性能很高但对没有心理预期的人来说是灾难。处理溢出的手段有三种需要大数时显式用BigInt比如BigInt(1) 100需要保持默认整数但又担心溢出时用Base.Checked模块里的checked_add这类函数溢出了就抛异常或者用boundscheck之前先用小范围测试锁死数据范围。我一般推荐在算法原型阶段用 BigInt 验证数学正确性然后在正式实现里换回 Int64 并加边界检查这样既能保证逻辑正确又能把热点代码的性能拿回来。类型提升promotion也是运算符自带的行为1 1.0返回2.0类型上自动提升到 Float641 2im返回复数。这套机制让 Julia 的数学表达式非常接近手写数学式子但代价是如果你在一个热循环里写1 x而x是 Float32结果会变成 Float64类型链就全乱了。调试这种问题最有效的工具是code_warntypejulia using InteractiveUtils julia code_warntype myfunc(1)它会标出哪些变量被推断成Union或Any一旦看到Any基本可以判断性能要出问题。结合运算符本身来看在 Julia 里本质是函数(a, b)不同类型的组合会走不同的方法分派类型推断越精确编译器越能把运算压成 CPU 单指令。4. 下标运算符、广播与自定义运算符Julia 的“高级日常”4.1 下标运算符从 1 开始的索引美学与 endJulia 的下标运算符[]继承自 MATLAB 的数学惯例数组索引从 1 开始。A[1]取第一个元素A[end]取最后一个元素A[end-1]取倒数第二个。从 C/Python 转过来第一天肯定会不习惯但这个设计对数学公式非常友好向量、矩阵的下标与教材里的x_1, x_2, ..., x_n天然对齐。end只能在[]内部使用它会被 Julia 解析器翻译成对应维度的长度所以你不需要写A[size(A,1)]这种啰嗦代码。多维下标也是 Julia 的特色A[i, j]同时访问两个维度切片A[1:3, 2]可以直接取出子块。更精巧的是视图语法view A[:, 1]创建一个引用原数组内存的视图零拷贝非视图的A[:, 1]则会复制一份数据。在高性能代码里能少复制就少复制这是内存管理的一条铁律。下标操作本质上是Base.getindex/Base.setindex!这两个函数的语法糖。如果你自己定义了矩阵、张量或特殊容器类型只需要重载这两个函数就能让用户用obj[i,j]访问数据obj[i,j] v写入数据。下标赋值表达式的求值结果是右侧的值所以A[1] 5这个表达式整体返回5这在 REPL 里会直接显示。4.2 广播点运算符内存优化的大杀器Julia 里的点号.不只是小数点它还能加在任何函数或运算符后面变成广播版本f.(x)、x . y、A .* B、x . y。广播的意思是“逐元素执行”它比手写for循环更符合直觉也比嵌套循环更容易写出无差错的代码。更关键的一点是连续的点运算会融合成单一循环。比如x . sin.(y) .* 2Julia 的广播器不会生成一串临时中间数组而是把整个表达式展开成一个遍历循环一次遍历同时完成 sin、乘法、加法。这与 Python 里x sin(y) * 2先创建多个中间 numpy 数组的路线完全不同也是 Julia 在内存优化上的一大卖点。“原地更新”在这个体系里是另一个大招。A . A . 1表示把计算结果写回 A 原本的内存A . f.(B)同理。只要目标变量已经存在.就不会分配新数组。我之前在 2.1 提到的a a . 1和a . 1的区别本质上就是“重新绑定名字”和“原地修改内容”的区别。在热循环里如果每轮都重新绑定一个大数组垃圾回收压力会非常大换成.原地更新内存曲线直接变平。用allocated或time看一眼分配量你会立刻理解这个点号的价值。4.3 自定义新运算符请克制但要知道怎么玩Julia 允许你定义新的运算符也可以给已有运算符添加新的参数类型方法。新运算符的定义很简单任何一串符号都可以当作函数名⊗(a, b) sqrt(a^2 b^2) julia 3 ⊗ 4 5.0在 REPL 里输入\otimes再按 Tab 就能打出⊗。这种做法在纯数学代码里很常见一群做大数运算、量子计算、范畴论的人会把自己领域里的符号直接写进代码交互体验接近论文。但我要泼一盆冷水自定义运算符的代价是极高的阅读门槛。中文环境下团队成员未必都能熟练输入 Unicode 符号代码审查、搜代码、聊天里复制都会出问题。我的建议是仅在领域专用、团队统一约定、且有明显语义增益的情况下使用否则老老实实写成命名函数。重载已有运算符则更危险因为直觉会被破坏。比如给某个自定义类型重载为拼接运算初看方便但其他开发者会默认是加法一旦语义与直觉背离就会在项目里埋下一颗定时炸弹。Julia 官方的态度是鼓励类型和方法扩展但要求你尊重语义边界。5. 常见问题与实战排查五张速查表帮你少踩坑5.1 新手翻车现场 Top 5把常见错误集中成一个表比读十个教程都管用症状原因正确写法MethodError: *之类字符串拼接失败Julia 字符串拼接不是hello * world9 / 2得到4.5但想要整数/永远返回浮点9 ÷ 2或div(9, 2)-7 % 3得到-1与 Python 不同Julia%是rem语义非负取模用mod(-7, 3)b a 1后b是nothing复合赋值返回nothing拆开写a 1; b a1 2 3结果是 32 而不是 12优先级高于移位明确成(1 2) 3A[i]期望第 0 个元素Julia 索引从 1 开始A[1]末尾用A[end]这张表我建议你复制到项目里的notes.md里每次遇到“这个结果怎么这么怪”的问题就回来查一遍。5.2 排查工具箱which、methods 和 REPL 帮助当我遇到一个运算符行为不符合预期时第一反应不是查文档而是让 Julia 自己告诉我它调用了什么。which 2 3会显示(::Int64, ::Int64)对应的具体方法methods()列出一堆的方法签名帮助理解为什么某些类型组合不成立REPL 里输入?÷或?会直接跳出对应运算符的帮助页面。这三大工具能解决 90% 的“基本运算符报错”问题。真实排查流程举例你写hello world报错先用which hello world看方法匹配大概率看到MethodError的提示指出没有对应的方法。这时你再去 REPL 里输入?看看帮助文档Julia 会告诉你是加法运算、对字符串不适用接着用?*就会发现字符串乘法拼接的说明。整个过程两分钟比搜索引擎快多了。还有一个很实用的技巧把未知行为写成一个最小复现脚本然后用code_warntype检查推导类型。如果推导变成了Any问题往往出在类型上如果是数值错位优先怀疑优先级如果结果对但性能差优先怀疑多余分配。5.3 我的操作习惯与三个建议踩过足够多的坑之后我养成了几个固定的操作习惯。第一个习惯是“拿不准就用括号”。运算符优先级跨语言差异太大尤其是位运算、比较运算、逻辑运算混排的时候写括号不只是给编译器看的更是给未来的自己看的。第二个习惯是“REPL 优先于文档”。每次切到一门新语言先把运算符挨个试一遍边试边记录哪些符号与之前语言的行为不一致这份记录就是最宝贵的速查表。第三个习惯是“热循环里警惕隐式分配”。凡是涉及大数组的连续运算我会先用time看分配量再用.原地更新最后用code_warntype检查类型稳定性。数据规模没上来之前这些优化看起来都很“小题大做”但一旦数据量上来问题会以十倍百倍的规模爆发。我的经验是一个项目从 Python 数据工程迁移到 Julia 时我花了一天看运算符差异然后写了一个脚本把项目里所有%全部换成mod或rem。表面上是替换符号实际上是替换了我对“余数语言”的理解。语言之间没有约定俗成的统一语义除非你把每个符号的边界摸清否则总有一个-7 % 3在下一个项目里等着你。最后分享一个个人体会Julia 的运算符系统整体上比多数语言更贴近数学直觉它把很多选择的权利交还给了使用者——除法可以精确、可以取整、可以保留有理数取余可以跟随被除数、也可以跟随除数比较可以是数值的、可以是对象的、也可以是全序的。这套设计在刚上手时显得符号太多、语义太重但等你真正写完一个含大量矩阵运算和自定义类型的项目后会发现这种“不替你决定”的态度反而是最省心的。第一次用 Julia 跑通一个需要处理复数、有理数、浮点、整数混合的公式时我把所有运算符逐一手写拆解一遍那种“原来每个符号背后都在做类型推断和方法分派”的感觉比单纯把代码跑出结果要踏实得多。祝你在 Julia 里写出既快又清晰的计算代码。如果这篇文章对你有一点帮助或是你也遇到过%和mod的坑欢迎在下面留言聊聊你踩过的运算符“翻车现场”。