new关键字大概是程序员最早背下来的语法之一,但也是最容易被随手带过的一个词。写代码的前两年,我觉得 new 就是在“变出一个对象”——new 一个日期、new 一个数组、new 一个组件,至于背后发生了什么,完全没想过。直到有一次排查线上问题,发现一段老代码里的构造函数没写 new,结果 this 跑到全局对象身上去了,页面数据全部错乱,才意识到:new 不只是语法,它是一整套对象创建流程的入口。这篇文章我把自己的踩坑记录和补课笔记整理在一起,覆盖 JavaScript、C++、Java 几门主流语言,也讲讲 new 在工程里的正确姿势。无论你是刚学会 new 的新手,还是写了多年但没细究过的老手,应该都能从里面找到点有用的东西。
1. 先别急着new:搞清楚它到底解决什么问题
1.1 new的本质:按图纸批量造实例
想象一下,类(或者构造函数)是一张图纸,对象是盖好的房子。new 就是施工队,它负责把图纸变成一栋能住人的房子。如果没有 new,你每次想创建对象,都得手动把图纸里的每一个属性抄一遍,抄出来的副本之间还要维护独立状态,抄得多了手都会废。
在 JavaScript 里,早期没有 class,但构造函数本身就是图纸。比如:
function Person(name, age) { this.name = name; this.age = age; } const a = new Person('张三', 18); const b = new Person('李四', 20);这里有两个关键点:第一,a 和 b 拥有相同的结构,但是互不影响;第二,它们都能通过原型链访问到 Person.prototype 上的方法。new 不只是简单执行一遍函数,更重要的动作是把实例和构造函数的 prototype 连接起来。这个点很多新手会忽略,以为 new 只是调用一下函数而已。
那为什么不能用普通的函数调用代替?如果你直接写Person('张三', 18),在非严格模式下,this 会指向全局对象,相当于给全局对象挂上了两个属性,然后函数返回 undefined。这种错误一旦上线,往往会造成全局变量污染,而且极难排查。所以 new 解决的不仅是“创建对象”这个需求,还包括“安全地、正确地初始化对象”这件事。理解了图纸和房子的类比,后面关于 new 的所有细节就都顺了。
1.2 同名叫new,不同语言的脾气天差地别
new 这个词在主流语言里随处可见,但它在不同语言中的责任完全不同。我列了一个速查表,你可以一眼看出差异。
| 语言 | new 的角色 | 创建对象后谁管内存 | 典型注意点 |
|---|---|---|---|
| JavaScript | 运算符,配合构造函数或 class 使用 | 运行时垃圾回收,自动管理 | 忘写 new 会污染作用域,箭头函数不能 new |
| C++ | 运算符,负责分配内存加构造 | 开发者自己 delete | new[] 要配 delete[],malloc/free 不要混用 |
| Java | 关键字,在堆上创建对象引用 | 垃圾回收器 | 小对象可能被运行时优化到栈上 |
| Python | 没有 new 关键字,类调用会触发new和init | 垃圾回收 | 底层是new在做对象创建 |
| Go | new(T) 分配零值内存,返回 *T | 垃圾回收 | 更常用 &T{} 字面量,new 的场景反而较少 |
可以看到,C++ 里的 new 更像“施工队亲自扛着水泥和砖盖房”,盖完你必须负责拆房;Java 里的 new 是“请物业公司盖房”,后续维护交给垃圾回收器;JavaScript 里的 new 则是“轻量装修队”,对象创建有一套自己的规矩,但最终回收不归你管;Python 干脆没把 new 暴露成关键字,而是把它藏在了__new__这个方法后面。
这个差异不是无意义的。理解一个程序里的 new 行为,首先要确定你所在语言的 new 到底承担了哪些职责。如果你把 C++ 的手动释放思路带到 JavaScript,代码里到处想着 delete,不仅没用,还会误导人;反过来,如果你用 JavaScript 的“new 只是调用一下函数”的直觉去写 C++,大概率会留下内存泄漏。后面我会分别拆解这些差异。
2. JavaScript里的new,四步拆解一次完整旅程
2.1 手写一个new,把规范翻译成人话
JavaScript 的 new 底层行为其实只做四件事:创建空对象、连接原型链、绑定 this 执行构造函数、根据返回值决定最终结果。你可以用十几行代码把它还原出来:
function myNew(Constructor, ...args) { // 第一步:基于构造函数的 prototype 创建一个空对象 const obj = Object.create(Constructor.prototype); // 第二步:把构造函数作为普通函数调用,this 指向 obj const result = Constructor.apply(obj, args); // 第三步:如果构造函数返回了一个对象,就用那个对象;否则用我们创建的 obj const isObj = result !== null && (typeof result === 'object' || typeof result === 'function'); return isObj ? result : obj; }这段模拟代码里的每一步都有规范依据。第一步最关键:obj 的原型必须指向 Constructor.prototype,这样 new 出来的实例才能访问构造函数原型上的方法。有人图省事,写{ __proto__: Constructor.prototype },效果差不多,但我建议用 Object.create,因为语义更清楚,也不会触发老解析器的一些怪异行为。
第二步,Constructor.apply(obj, args)相当于把构造函数里的 this 绑定到 obj 上。如果你在构造函数里写this.name = xxx,实际上是在给 obj 添加属性。注意,apply 的返回值我们接收了,这就是第三步要处理的特殊情况。构造函数通常不写 return,默认返回 undefined;但有些构造函数会显式 return 一个对象。
第三步是新手最陌生的规则:如果构造函数显式返回的是一个对象(包括函数),那么 new 表达式的值就用这个返回值,而不是我们创建的 obj;如果返回的是原始类型(字符串、数字、布尔等),则会被忽略,仍然返回 obj。这个规则看起来有点反直觉,但它是规范明确规定的,后面我会举实际例子。
还有一点:这个模拟函数本身不能用来构造 class,因为 class 必须通过 new 调用,不能通过 apply 以普通函数方式调用。要完整模拟 class 的构造行为,还需要更多检查,这里就不展开了。手写 new 的意义不在于让你在工作里真的去替换 new,而是让你把这一整套流程刻进脑子里,之后遇到奇怪的对象创建问题,能第一时间判断是哪一步出了岔子。
2.2 构造函数返回对象时,new结果会被“掉包”
这个坑我在实际项目中遇到过。当时需要封装一个内部组件,构造函数里为了复用缓存,显式 return 了一个已有实例。结果用 new 创建出来的新变量,判断 instanceof 自己所属的类时返回 false,整个团队排查了很久。原因就是第三步:构造函数返回了一个对象,new 出来的结果就变成了那个对象。
简单演示一下:
function Foo() { this.name = 'foo'; return { name: 'bar' }; } const f = new Foo(); console.log(f.name); // 'bar' console.log(f instanceof Foo); // false因为你new Foo()得到的根本不是 Foo 的实例,而是构造函数内部返回的那个对象。但如果你 return 的是数字或字符串:
function Bar() { this.name = 'bar'; return 42; } const b = new Bar(); console.log(b.name); // 'bar' console.log(b instanceof Bar); // true返回 42 这个原始值会被直接忽略,new 依旧返回构造过程中创建的实例。所以,如果你在写构造函数时想要复用某个对象,一定要谨慎使用 return 对象这种写法。尤其是引用第三方库的构造函数,如果它返回 Promise、DOM 元素等对象,new 出的结果未必是“类实例”,这会直接影响 instanceof、原型链方法等后续逻辑。
那怎么检查一个 new 表达式有没有被掉包?很简单,把成员变量都打出来,或者用Object.getPrototypeOf(result)看它的原型指向。如果发现原型不是期望的 Constructor.prototype,基本就是被返回值覆盖了。
2.3 new、class和箭头函数之间那些不能碰的组合
JavaScript 里有几种函数形态对 new 的支持完全不一样。普通函数既能当构造函数,也能当普通函数调用;class 只能 new 调用,直接调用会抛 TypeError;箭头函数既不能当构造函数,也没有 prototype,因此对它 new 会直接报错。
为什么箭头函数不能 new?因为箭头函数的设计目标是词法 this,它自己没有独立的 this,也没有 arguments,而且没有 prototype 属性。new 的核心操作之一就是把 this 绑定到新对象,箭头函数连 this 都没有,自然没办法完成构造过程。所以你在代码里看到new (() => {})这种写法,运行时会直接给你一个错误。
class 只能在 new 时调用,这一点其实是语言对开发者的保护,避免了“忘写 new 污染全局”这类低级错误。ES6 引入 class 之后,很多团队就默认不再写 function 构造函数了,改用 class 来约束大家必须 new。
还有一个很有用的语法:new.target。在函数内部检查 new.target,可以知道当前函数是否被 new 调用:
function Person(name) { if (!new.target) { return new Person(name); } this.name = name; }这样即使有人不小心漏写 new,函数内部也会自动补上 new,然后把正确的实例返回出去。这是老代码兼容性保护里很好用的一招,比判断this instanceof Person要稳妥得多。
3. 换C++和Java再看new:内存所有权和引用语义的差异
3.1 C++的new是和delete配对出现的,别把两个世界搞混
C++ 里的 new 和 JavaScript 的 new 完全是两码事。JavaScript 里 new 只是对象初始化的语法入口,内存由运行时管理;C++ 里 new 是真正的内存运算符,它会做两件事:先调用 operator new 分配足够的内存,再在这块内存上调用构造函数。如果你不调用 delete,那块内存就永远不会自动释放。
这个区别必须刻在脑子里:在 C++ 里,new 和 delete 是对称操作,用了 new 就必须在某个地方用 delete。new[] 配 delete[],new 配 delete,不能混着来。否则就是野指针、越界崩溃或者内存泄漏。C++ 还支持 placement new,它的作用是“在一个已经存在的内存地址上构造对象”,不分配新内存。这个特性在嵌入式、对象池等场景下很常用,但特别容易踩坑,因为你得手动调用析构函数结束对象生命周期,然后自己管理那块原始内存。
// 普通的堆对象 Foo* foo = new Foo(1, 2); delete foo; // 对象数组 Foo* arr = new Foo[10]; delete[] arr; // placement new 不在堆上分配,只在已有内存上构造 void* buf = malloc(sizeof(Foo)); Foo* foo2 = new (buf) Foo(1, 2); foo2->~Foo(); free(buf);这里还有一个容易被忽略的细节:如果构造函数抛出了异常,C++ 标准保证编译器会调用 operator delete 释放已经分配的内存,不会让裸 new 因为构造失败而泄漏。但这只对“new T(构造函数参数)”这种形式有效;如果是先new []再在循环里构造对象,中间某个对象构造抛异常,前面已经构造好的对象会被析构,数组内存也会被释放。但如果你在数组完全构造完成后再手动管理,一旦漏了 delete[],内存泄漏照样发生。这类逻辑一旦复杂起来,手写 new/delete 非常容易出错。
所以现代 C++ 的工程建议是:尽量不用裸 new/delete,改用智能指针 unique_ptr、shared_ptr 和容器,把内存所有权交代清楚。new 仍然存在,但应该藏在 make_unique、make_shared 这类工厂函数的内部,业务代码里不直接出现。
3.2 Java里new只是第一步,后面的故事交给GC
Java 里的 new 在语法上看起来最简单,但实际上也是“创建对象”的完整过程:先检查类是否已加载、初始化类、分配堆内存、设置对象头、调用构造方法。但内存释放完完全全交给垃圾回收器,你不需要写 delete,也没有传统意义上的析构函数。
如果你把字节码反汇编出来看,一个new Xxx()会被拆成 new、dup、invokespecial 三条指令。new 指令在操作数栈中压入一个指向新对象的引用,dup 复制一份引用给栈顶,最后 invokespecial 调用构造方法。这一套操作之所以要 dup,是因为后面多半还要把对象引用存到变量或数组里,栈顶和后续指令都需要引用。听起来很底层,但它能帮你理解 Java 里“变量其实是引用”这件事。
Person p = new Person("张三");这句代码做的事情是:在堆上创建一个 Person 对象,然后把对象的引用复制给变量 p。真正的对象一直在堆上,p 只是一个指向它的引用。如果你把 p 赋值给另一个变量 q,它们指向同一个对象,修改 q 的字段会影响到 p 看到的值。这就是引用语义和值语义的区别。
Java 的 new 还有一个现代运行时才有的变数:逃逸分析和栈上分配。简单说,如果即时编译器分析发现这个 new 出来的对象没有“逃逸”出当前方法,它可能会在栈上直接分配,避免进堆,这样也不需要垃圾回收参与。我们不必把细节背下来,但知道 new 并不保证“一定在堆上”这一点,对理解性能问题很有帮助。在做高频小对象创建的优化时,栈上分配能明显降低 GC 压力,这也是为什么很多工具类敢频繁 new 临时对象。
4. 生产环境里new最容易踩的坑(我全踩过)
4.1 忘写new:this跑丢引起的连锁反应
在 JavaScript 老代码里,函数构造函数是极其常见的写法。如果某处调用时少写了一个 new,函数会以普通函数方式执行,this 的指向取决于调用上下文。在浏览器非严格模式下,this 直接指向全局对象,于是构造函数里的this.name = xxx就变成了给全局对象挂属性。这一来,页面里所有同名变量的行为都可能被影响,排查起来非常痛苦。
function User(name) { this.name = name; } const u = User('张三'); // 忘了 new console.log(u); // undefined console.log(window.name); // '张三'严格模式下好一点,this 会是 undefined,构造函数第一行this.name = name就直接抛 TypeError,异常可以早一点暴露。但不管哪种情况,调用方都拿不到期望的实例。更头疼的是库代码:使用方把构造函数传给第三方库,库内部如果不规范,可能直接用普通函数方式调用,所有实例化都会静默失效。ES6 的 class 从语言层面禁止了直接调用,才把这个坑堵上。
如果要在旧代码里做防御,可以在函数开头检查 new.target:
function User(name) { if (!new.target) { return new User(name); } this.name = name; }这样就算漏写 new,也能得到一个正确实例。但要注意,这样写会多一次函数调用,性能敏感的热路径上要评估。不过绝大多数业务代码不会差这一次调用,值得。
4.2 构造函数返回对象:instanceof直接失效
这个问题前面已经说过,但实际生产环境里它藏得更深。比如某个组件构造函数里为了缓存,return 了一个已存在的对象;或者为了支持链式调用,return 了一个包装后的代理。此时 new 表达式的结果并不是“构造出来的实例”,instanceof 返回 false,而代码里如果依赖 instanceof 做类型分支,就会走进完全错误的逻辑分支。
我自己遇到的一次是这样的:一个内部工具类的构造函数里,根据参数长度决定复用某个老实例还是创建新实例,于是直接 return 了另一个对象。结果所有通过 new 创建出来的变量,都指向同一个老实例,后续给不同变量设置属性时,互相覆盖。这种 bug 只看代码很难发现,因为构造函数内部的 return 对象写得很隐蔽,只有跑到那行代码才会触发。
排查建议是:new 出对象后,用Object.getPrototypeOf(obj)去观察它的原型,而不是只看变量打印出来的字段。如果发现原型链不对,立刻检查构造函数末尾有没有 return 对象。
4.3 C++裸new:一半的bug都出在配对和异常安全上
C++ 的裸 new 坑,本质是生命周期管理问题。最典型的是:一个函数里 new 了一个对象,中途提前 return,导致 delete 永远没执行;更隐蔽的是,new 了两个对象,第二个 new 抛异常,第一个对象就泄漏了。这种情况在 C++ 老代码里频繁出现,尤其在资源获取和释放分散在多个 if 分支里时。
void process() { Resource* a = new Resource(); Resource* b = new Resource(); // 如果这里抛异常,a 就泄漏了 // ... delete b; delete a; }为了避免,现代 C++ 的做法是尽量不裸 new。int* p = new int(5)改成auto p = std::make_unique<int>(5),对象数组用 std::vector,需要共享再用 std::shared_ptr。new 并不是不能用,而是要让它的生命周期被 RAII 对象管理,这样异常和提前 return 都能安全释放。如果一个项目里还有大量裸 new,代码审查时我基本会直接要求重构。
5. 工程实践:new在项目里什么时候该用、什么时候该让路
5.1 能用字面量的时候,尽量别写new
JavaScript 里有一类非常典型的“伪需求”:new String、new Number、new Boolean、new Array、new Object。这些写法理论上能创建对象,但生产环境中几乎全是坑。new String('hello')创建的是 String 对象,不是字符串原始值;typeof 的结果是 'object';用全等比较时,new String('hello') === 'hello'返回 false。大多数人根本没有这种需求,完全可以全部用字面量替代。
const bad = new String('hello'); // 不推荐 const good = 'hello'; const badArr = new Array(3); // [,,,] 长度为3的空数组,不是[3] const goodArr = [3]; // [3]new Array(3)尤其容易误导人,它创建的是一个长度为 3、元素全是空位(empty)的数组,而不是数组 [3]。如果你是想初始化一个数组,直接用字面量或者 Array.from。new Object 同理,一个字面量 {} 就够了。像 Date、RegExp、Map、Set 这些没有字面量形式的对象,用 new 是完全合理的。
5.2 工厂模式里new的正确用法:把选择权集中起来
业务代码里避免到处 new,但 new 并不意味着要禁掉。一个很典型的正向用法是工厂函数:把实例创建的逻辑集中到一个入口,由这个入口根据条件决定具体 new 哪个类。
我举个例子。假设你在维护一个组件库,用户通过 type 字段请求创建不同类型的组件:
class Button { constructor(label) { this.label = label; } render() { /* 渲染按钮 */ } } class Slider { constructor(min, max) { this.min = min; this.max = max; } render() { /* 渲染滑块 */ } } function createComponent(config) { switch (config.type) { case 'button': return new Button(config.label); case 'slider': return new Slider(config.min, config.max); default: throw new Error(`unknown type: ${config.type}`); } }这样做的好处在于:调用方不需要知道 Button、Slider 这些具体类的构造函数签名,新增组件类型时只需要改工厂函数,而不是改每一个调用点。而且你可以在工厂里统一做校验、缓存、日志等横切逻辑。new 仍然出现在工厂里,但它是受控的,不像业务代码里到处分散。这种风格在框架源码里非常常见,本质上是工厂模式的基础。
5.3 别在业务代码里到处new:依赖注入与可维护性
除了工厂模式,另一个要聊的问题是依赖耦合。如果你在业务代码里每处用到服务的地方都写new SomeService(),那么服务的创建和业务逻辑就绑在了一起。后续如果要换实现、加代理、写单元测试,就得把所有 new 的位置都改一遍,代价很大。比较好的做法是构造器传入依赖,或者通过某个容器统一创建。
class ReportService { constructor(repo) { this.repo = repo; } createReport(id) { return this.repo.findById(id); } } // 上层组装依赖 const repo = new ReportRepository(dbConfig); const service = new ReportService(repo);这里 new 仍然有,但被限制在“组装根”上,业务逻辑内部不出现依赖对象的创建。这种代码的测试友好度会好很多,因为你可以注入一个 mock repo,而不必真的 new 一个带数据库连接的实现。
6. 遇到new的问题,直接来查这张表和几个小技巧
6.1 高频问题速查
我整理了一个高频问题速查表,遇到问题可以直接对号入座。
| 现象 | 问题原因 | 解决办法 |
|---|---|---|
new Foo()报错 “is not a constructor” | Foo 是箭头函数,没有构造能力 | 换成普通函数或 class |
| class 直接调用报错 | class 必须通过 new 调用 | 先 new 再使用 |
| 忘了写 new 导致 this 指向全局 | 构造函数被当成普通函数调用 | 用 new.target 做兜底 |
| instanceof 返回 false | 构造函数内部 return 了一个对象,new 结果被覆盖 | 检查构造函数返回值和 Object.getPrototypeOf |
| new 出来的对象属性丢失 | 构造函数返回了一个不完整对象 | 去掉 return 对象,或重构为工厂 |
| C++ 程序 delete 时崩溃 | new[] 配了 delete,或 delete 了栈对象 | new[] 配 delete[],不要混用 |
| C++ new 两个对象,第二个抛异常,第一个泄漏 | 没有 RAII 管理,栈展开不会自动 delete 裸指针 | 使用智能指针或容器 |
| Java 频繁 new 大量小对象,GC 压力大 | 对象创建密集 | 考虑对象池或者依赖逃逸分析优化后的栈上分配 |
new String('x') === 'x'为 false | 创建了包装对象,而不是原始值 | 全部使用字面量 |
| Go 用 new(T) 赋值后字段没法初始化 | new 只分配零值内存 | 改用&T{}字面量 |
| Python 里没有 new 关键字 | 对象创建在底层由__new__完成 | 正常调用类即可,别去找 new 语法 |
6.2 一个小技巧:用new.target提前规避低级失误
在支持 new.target 的环境中,你可以在构造函数开头加一个断言,尤其适合库代码。比如:
function User(name) { if (!new.target) { throw new TypeError('User 必须使用 new 调用'); } this.name = name; }如果你的团队老代码还大量使用函数构造函数,可以在公共代码检查工具里配置规则,要求构造函数首字母大写,并且必须配合 new 调用。如果你已经在用 class,这个问题基本可以从语言层面消失。
最后再分享一个我自己的调试习惯:凡是 new 出来的对象,我习惯第一时间打印Object.getPrototypeOf(obj) === SomeClass.prototype。如果为 true,说明原型链正确;如果为 false,就顺着构造函数返回值去查。这个习惯帮我避过不少隐性 bug,也希望能在你排查 new 相关问题时派上用场。