[Clang][Sema] Synthesize a memcpy body for defaulted union assignment (#206579)

A defaulted copy or move assignment operator for a union is synthesized
with an empty body. `DefineImplicitCopyAssignment` /
`DefineImplicitMoveAssignment` skip union members in the memberwise
loop, and the implied copy of the object representation has no AST
representation, a FIXME that has sat at that skip for a long time. The
operator ends up copying nothing.

Classic CodeGen hides this at ordinary call sites by lowering a trivial
assignment to a memcpy at the call site, so `u1 = u2` works even though
the operator body is a no-op. But when the operator is genuinely called,
through a pointer-to-member for instance, it silently copies nothing.
ClangIR calls the assignment operator at the call site rather than
eliding it, so it hits the empty body directly and drops every union
assignment. The no-op is then deleted at `-O3`. That is the MultiSource
`kc` miscompile, where a `YYSTYPE` union assignment (`*++yyvsp =
yylval`) becomes a no-op.

This implements the FIXME by mirroring the defaulted union copy
constructor (`CGClass.cpp`: "union copy constructor, we must emit a
memcpy") in the AST: when the class is a union,
`DefineImplicit{Copy,Move}Assignment` emits a single whole-object copy
through `buildMemcpyForAssignmentOp`, the helper already used for
trivially-copyable array members, instead of the skipped per-member
assignments. The operator stays trivial (triviality is fixed at
declaration time, before the body is synthesized), so constant
evaluation, which copies trivial unions through its own semantic path
and never executes the body, is unchanged.

Copying the object representation is correct even when a union member is
not trivially copyable, but the synthesized memcpy would then trip
`-Wnontrivial-memcall`. A `void*` cast would drop pointee qualifiers
such as the address space and inject an explicit cast node the source
never wrote. Instead the call keeps its typed union-pointer arguments,
and the union branch in `DefineImplicit{Copy,Move}Assignment` wraps the
build in `IgnoreAllWarningDiagRAII`, a save-and-restore around the
engine's existing ignore-all-warnings state. `CheckMemaccessArguments`
defers the warning and its note through `DiagRuntimeBehavior`, and that
queue flushes after the RAII has restored the previous ignore state, so
`DiagIfReachable` samples that state as it enqueues and drops what is
not error-class. Under `-w` the same queue is discarded before it
flushes, so nothing outside the scoped region changes.

Classic CodeGen now emits the copy when the operator is odr-used, and
ClangIR's union assignment lowers to a real memcpy. On the Sema side,
tests cover the synthesized AST for the implicit-`this` and C++23
explicit-object spellings, the `-Wnontrivial-memcall` suppression, the
preserved address space, the clean `-ast-print` output, and constexpr
active-member copy. The classic and ClangIR lowering is checked across
named, anonymous, and tail-padded unions.

#198918 added a defaulted-union `errorNYI` in ClangIR and deferred this
AST fix to a separate change. This PR makes that fix and drops the
guard, so the now-non-empty body lowers to a `cir.call @memcpy` instead
of erroring. Its own `union-copy-move-assignment.cpp` CIR test, which
expects that call, is the regression check.
This commit is contained in:
Adam Smith 2026-07-31 15:49:40 -05:00 committed by GitHub
parent 6512cd96dd
commit ec2bf56676
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 340 additions and 33 deletions

View file

@ -418,6 +418,12 @@ features cannot lower the translation-unit ABI level;
libstdc++15 has been extended to support preprocessed input. Previously, splitting the preprocessing and
compilation step would result in the fix not being applied. (#GH160314)
- A defaulted copy or move assignment operator for a union was left with an
empty body and copied nothing when the operator was actually called, for
example through a pointer to member. Clang now synthesizes a whole-object
copy so the union's object representation is copied, matching the defaulted
union copy constructor.
#### Bug Fixes to AST Handling
- Fixed a non-deterministic ordering of unused local typedefs that made

View file

@ -1124,6 +1124,22 @@ public:
}
};
/// RAII class that temporarily sets the "ignore all warnings" state on a
/// DiagnosticsEngine and restores the previous state on destruction. Use it to
/// silence warnings around a self-contained region of diagnostics, such as a
/// compiler-synthesized call whose arguments are known to be correct.
class IgnoreAllWarningDiagRAII {
DiagnosticsEngine &Diag;
bool OldValue;
public:
explicit IgnoreAllWarningDiagRAII(DiagnosticsEngine &Diag)
: Diag(Diag), OldValue(Diag.getIgnoreAllWarnings()) {
Diag.setIgnoreAllWarnings(true);
}
~IgnoreAllWarningDiagRAII() { Diag.setIgnoreAllWarnings(OldValue); }
};
/// The streaming interface shared between DiagnosticBuilder and
/// PartialDiagnostic. This class is not intended to be constructed directly
/// but only as base class of DiagnosticBuilder and PartialDiagnostic builder.

View file

@ -901,20 +901,6 @@ void CIRGenFunction::emitImplicitAssignmentOperatorBody(FunctionArgList &args) {
assert(!cir::MissingFeatures::incrementProfileCounter());
assert(!cir::MissingFeatures::runCleanupsScope());
// A defaulted union copy/move assignment has an empty synthesized body:
// Sema skips union fields (the FIXME in SemaDeclCXX::buildSingleCopyAssign),
// so there is no AST expression for the implied whole-object memcpy.
// Emitting that body would silently drop the copy, so report NYI instead.
// Struct/array memcpy-equivalent assignments carry the implicit memberwise
// copies in the AST (per-field assignment expressions, or a builtin memcpy
// call for array members) and lower correctly through the loop below.
if (assignOp->isMemcpyEquivalentSpecialMember(getContext()) &&
assignOp->getParent()->isUnion()) {
cgm.errorNYI(assignOp->getSourceRange(),
"defaulted union copy/move assignment operator");
return;
}
// Classic codegen uses a special class to attempt to replace member
// initializers with memcpy. We could possibly defer that to the
// lowering or optimization phases to keep the memory accesses more

View file

@ -15507,10 +15507,30 @@ void Sema::DefineImplicitCopyAssignment(SourceLocation CurrentLocation,
Statements.push_back(Copy.getAs<Expr>());
}
// A defaulted copy assignment operator for a union copies the object
// representation as if by a memcpy, the same way the defaulted union copy
// constructor does. The memberwise loop below skips union members.
if (ClassDecl->isUnion()) {
ExprBuilder &To = ExplicitObject
? static_cast<ExprBuilder &>(*ExplicitObject)
: static_cast<ExprBuilder &>(*DerefThis);
// Copying the object representation is correct even for a union that is
// not trivially copyable, so -Wnontrivial-memcall is a false positive
// here. Ignoring warnings rather than casting the arguments to void*
// keeps them typed, which preserves their address space.
IgnoreAllWarningDiagRAII IgnoreWarnings(Diags);
StmtResult Copy = buildMemcpyForAssignmentOp(
*this, Loc, Context.getCanonicalTagType(ClassDecl), To, OtherRef);
if (Copy.isInvalid()) {
CopyAssignOperator->setInvalidDecl();
return;
}
Statements.push_back(Copy.getAs<Stmt>());
}
// Assign non-static members.
for (auto *Field : ClassDecl->fields()) {
// FIXME: We should form some kind of AST representation for the implied
// memcpy in a union copy operation.
// Union members are copied by the whole-object memcpy emitted above.
if (Field->isUnnamedBitField() || Field->getParent()->isUnion())
continue;
@ -15897,10 +15917,30 @@ void Sema::DefineImplicitMoveAssignment(SourceLocation CurrentLocation,
Statements.push_back(Move.getAs<Expr>());
}
// A defaulted move assignment operator for a union copies the object
// representation as if by a memcpy, the same way the defaulted union copy
// constructor does. The memberwise loop below skips union members.
if (ClassDecl->isUnion()) {
ExprBuilder &To = ExplicitObject
? static_cast<ExprBuilder &>(*ExplicitObject)
: static_cast<ExprBuilder &>(*DerefThis);
// Copying the object representation is correct even for a union that is
// not trivially copyable, so -Wnontrivial-memcall is a false positive
// here. Ignoring warnings rather than casting the arguments to void*
// keeps them typed, which preserves their address space.
IgnoreAllWarningDiagRAII IgnoreWarnings(Diags);
StmtResult Copy = buildMemcpyForAssignmentOp(
*this, Loc, Context.getCanonicalTagType(ClassDecl), To, OtherRef);
if (Copy.isInvalid()) {
MoveAssignOperator->setInvalidDecl();
return;
}
Statements.push_back(Copy.getAs<Stmt>());
}
// Assign non-static members.
for (auto *Field : ClassDecl->fields()) {
// FIXME: We should form some kind of AST representation for the implied
// memcpy in a union copy operation.
// Union members are copied by the whole-object memcpy emitted above.
if (Field->isUnnamedBitField() || Field->getParent()->isUnion())
continue;

View file

@ -21114,6 +21114,14 @@ bool Sema::DiagIfReachable(SourceLocation Loc, ArrayRef<const Stmt *> Stmts,
}
if (getCurFunction()) {
// This queue flushes after the function is analyzed, by which time an
// ignore-all-warnings region live here is gone, so sample it now. A note
// is not error-class either, so this also drops the notes that accompany a
// skipped warning. They arrive on their own call, out of reach of the
// engine's rule that drops a note whose warning was ignored.
if (Diags.getIgnoreAllWarnings() &&
Diags.getDiagnosticIDs()->isWarningOrExtension(PD.getDiagID()))
return false;
FunctionScopes.back()->PossiblyUnreachableDiags.push_back(
sema::PossiblyUnreachableDiag(PD, Loc, Stmts));
return true;

View file

@ -0,0 +1,23 @@
// RUN: %clang_cc1 -triple spir-unknown-unknown -cl-std=clc++2021 -x cl \
// RUN: -ast-dump %s | FileCheck %s
union U {
int a;
float b;
};
__kernel void k(__global U *g, __global const U *s) {
*g = *s;
}
// Converting the typed union pointers preserves the address space, which a
// compiler-written cast to plain void * would drop.
// CHECK: CXXMethodDecl {{.*}} implicit {{.*}}operator= '__generic U &(const __generic U &)
// CHECK: CompoundStmt
// CHECK: CallExpr
// CHECK: DeclRefExpr {{.*}} '__builtin_memcpy'
// CHECK: ImplicitCastExpr {{.*}} '__generic void *' <BitCast>
// CHECK: UnaryOperator {{.*}} '__generic U *' prefix '&'
// CHECK: ImplicitCastExpr {{.*}} 'const __generic void *' <BitCast>
// CHECK: UnaryOperator {{.*}} 'const __generic U *' prefix '&'

View file

@ -0,0 +1,35 @@
// RUN: %clang_cc1 -triple x86_64-unknown-linux-gnu -std=c++2b -ast-dump %s | FileCheck %s
union U {
int a;
float b;
U &operator=(this U &self, const U &) = default;
U &operator=(this U &self, U &&) = default;
};
void odr_use(U &x, const U &y, U &&z) {
x = y;
x = static_cast<U &&>(z);
}
// C++23 explicit-object form uses the same typed-pointer memcpy.
// CHECK: CXXMethodDecl {{.*}} operator= 'U &(U &, const U &)
// CHECK: CompoundStmt
// CHECK: CallExpr
// CHECK: DeclRefExpr {{.*}} '__builtin_memcpy'
// CHECK: ImplicitCastExpr {{.*}} 'void *' <BitCast>
// CHECK: UnaryOperator {{.*}} 'U *' prefix '&'
// CHECK: ImplicitCastExpr {{.*}} 'const void *' <BitCast>
// CHECK: UnaryOperator {{.*}} 'const U *' prefix '&'
// CHECK: ReturnStmt
// CHECK: CXXMethodDecl {{.*}} operator= 'U &(U &, U &&)
// CHECK: CompoundStmt
// CHECK: CallExpr
// CHECK: DeclRefExpr {{.*}} '__builtin_memcpy'
// CHECK: ImplicitCastExpr {{.*}} 'void *' <BitCast>
// CHECK: UnaryOperator {{.*}} 'U *' prefix '&'
// CHECK: ImplicitCastExpr {{.*}} 'const void *' <BitCast>
// CHECK: UnaryOperator {{.*}} 'U *' prefix '&'
// CHECK: ReturnStmt

View file

@ -0,0 +1,34 @@
// RUN: %clang_cc1 -triple x86_64-unknown-linux-gnu -std=c++17 -ast-dump %s | FileCheck %s
union U {
int a;
float b;
};
void odr_use(U &x, const U &y, U &&z) {
x = y;
x = static_cast<U &&>(z);
}
// The memcpy operands are typed union pointers, converted to void * only by
// the builtin's parameter.
// CHECK: CXXMethodDecl {{.*}} implicit {{.*}}operator= 'U &(const U &)
// CHECK: CompoundStmt
// CHECK: CallExpr
// CHECK: DeclRefExpr {{.*}} '__builtin_memcpy'
// CHECK: ImplicitCastExpr {{.*}} 'void *' <BitCast>
// CHECK: UnaryOperator {{.*}} 'U *' prefix '&'
// CHECK: ImplicitCastExpr {{.*}} 'const void *' <BitCast>
// CHECK: UnaryOperator {{.*}} 'const U *' prefix '&'
// CHECK: ReturnStmt
// CHECK: CXXMethodDecl {{.*}} implicit {{.*}}operator= 'U &(U &&)
// CHECK: CompoundStmt
// CHECK: CallExpr
// CHECK: DeclRefExpr {{.*}} '__builtin_memcpy'
// CHECK: ImplicitCastExpr {{.*}} 'void *' <BitCast>
// CHECK: UnaryOperator {{.*}} 'U *' prefix '&'
// CHECK: ImplicitCastExpr {{.*}} 'const void *' <BitCast>
// CHECK: UnaryOperator {{.*}} 'U *' prefix '&'
// CHECK: ReturnStmt

View file

@ -0,0 +1,21 @@
// RUN: %clang_cc1 -triple x86_64-unknown-linux-gnu -std=c++17 -ast-print %s | FileCheck %s
union U {
int a;
float b;
U &operator=(const U &) = default;
U &operator=(U &&) = default;
};
void odr_use(U &x, const U &y, U &&z) {
x = y;
x = static_cast<U &&>(z);
}
// The synthesized memcpy body must not leak into -ast-print.
// CHECK: union U {
// CHECK: U &operator=(const U &) noexcept = default;
// CHECK: U &operator=(U &&) noexcept = default;
// CHECK-NOT: __builtin_memcpy
// CHECK-NOT: (void *)

View file

@ -1,15 +0,0 @@
// RUN: %clang_cc1 -std=c++11 -triple x86_64-unknown-linux-gnu -fclangir -emit-cir -verify %s
// The defaulted copy/move assignment operator of a union has an empty
// synthesized body -- Sema skips union fields, leaving no AST expression for
// the implied whole-object copy. Emitting that body would silently drop the
// copy, so CIRGen reports NYI instead.
// expected-error@+1 2 {{ClangIR code gen Not Yet Implemented: defaulted union copy/move assignment operator}}
union U {
void *p;
int i;
};
void copy_assign(U &a, U &b) { a = b; }
void move_assign(U &a, U &b) { a = static_cast<U &&>(b); }

View file

@ -0,0 +1,33 @@
// RUN: %clang_cc1 -triple x86_64-unknown-linux-gnu -fclangir -emit-cir %s -o %t.cir
// RUN: FileCheck --check-prefix=CIR --input-file=%t.cir %s
// RUN: %clang_cc1 -triple x86_64-unknown-linux-gnu -fclangir -emit-llvm %s -o %t-cir.ll
// RUN: FileCheck --check-prefixes=LLVM,LLVMCIR --input-file=%t-cir.ll %s
// RUN: %clang_cc1 -triple x86_64-unknown-linux-gnu -emit-llvm %s -o %t.ll
// RUN: FileCheck --check-prefixes=LLVM,OGCG --input-file=%t.ll %s
union U {
int a;
float b;
};
// Odr-use both defaulted assignment operators out of line so their bodies are
// emitted under both backends.
auto get_copy = static_cast<U &(U::*)(const U &)>(&U::operator=);
auto get_move = static_cast<U &(U::*)(U &&)>(&U::operator=);
// CIR: cir.func{{.*}}@_ZN1UaSERKS_{{.*}}cxx_assign<!rec_U, copy, trivial true>
// CIR: cir.call @memcpy(
// CIR: cir.func{{.*}}@_ZN1UaSEOS_{{.*}}cxx_assign<!rec_U, move, trivial true>
// CIR: cir.call @memcpy(
// The CIR backend calls the memcpy libcall where the classic backend emits the
// llvm.memcpy intrinsic.
// LLVM: define linkonce_odr noundef nonnull align 4 dereferenceable(4) ptr @_ZN1UaSERKS_(ptr noundef nonnull align 4 dereferenceable(4) %{{.+}}, ptr noundef nonnull align 4 dereferenceable(4) %{{.+}})
// LLVMCIR: call ptr @memcpy(ptr noundef %{{.+}}, ptr noundef %{{.+}}, i64 noundef 4)
// LLVMCIR-NOT: call ptr @memcpy
// OGCG: call void @llvm.memcpy.p0.p0.i64(ptr align 4 %{{.+}}, ptr align 4 %{{.+}}, i64 4, i1 false)
// OGCG-NOT: call void @llvm.memcpy
// LLVM: define linkonce_odr noundef nonnull align 4 dereferenceable(4) ptr @_ZN1UaSEOS_(ptr noundef nonnull align 4 dereferenceable(4) %{{.+}}, ptr noundef nonnull align 4 dereferenceable(4) %{{.+}})
// LLVMCIR: call ptr @memcpy(ptr noundef %{{.+}}, ptr noundef %{{.+}}, i64 noundef 4)
// OGCG: call void @llvm.memcpy.p0.p0.i64(ptr align 4 %{{.+}}, ptr align 4 %{{.+}}, i64 4, i1 false)

View file

@ -0,0 +1,59 @@
// RUN: %clang_cc1 -triple x86_64-unknown-linux-gnu -std=c++17 -emit-llvm %s -o - | FileCheck %s
union U {
int a;
float b;
};
// Odr-use both defaulted assignment operators out of line so their bodies are
// emitted (a trivial assignment at a call site is otherwise memcpy'd directly).
auto get_copy = static_cast<U &(U::*)(const U &)>(&U::operator=);
auto get_move = static_cast<U &(U::*)(U &&)>(&U::operator=);
// Exactly one whole-object memcpy per assignment body.
// CHECK-LABEL: define linkonce_odr noundef nonnull align 4 dereferenceable(4) ptr @_ZN1UaSERKS_(ptr noundef nonnull align 4 dereferenceable(4) %{{.+}}, ptr noundef nonnull align 4 dereferenceable(4) %{{.+}})
// CHECK: call void @llvm.memcpy.p0.p0.i64(ptr align 4 %{{.+}}, ptr align 4 %{{.+}}, i64 4, i1 false)
// CHECK-NOT: memcpy
// CHECK: ret ptr
// CHECK-LABEL: define linkonce_odr noundef nonnull align 4 dereferenceable(4) ptr @_ZN1UaSEOS_(ptr noundef nonnull align 4 dereferenceable(4) %{{.+}}, ptr noundef nonnull align 4 dereferenceable(4) %{{.+}})
// CHECK: call void @llvm.memcpy.p0.p0.i64(ptr align 4 %{{.+}}, ptr align 4 %{{.+}}, i64 4, i1 false)
// CHECK-NOT: memcpy
// CHECK: ret ptr
union Padded {
int a;
char b[5];
};
// sizeof(Padded) == 8, so the whole-object copy includes the tail padding.
auto get_copy_padded = static_cast<Padded &(Padded::*)(const Padded &)>(&Padded::operator=);
// CHECK-LABEL: define linkonce_odr noundef nonnull align 4 dereferenceable(8) ptr @_ZN6PaddedaSERKS_(ptr noundef nonnull align 4 dereferenceable(8) %{{.+}}, ptr noundef nonnull align 4 dereferenceable(8) %{{.+}})
// CHECK: call void @llvm.memcpy.p0.p0.i64(ptr align 4 %{{.+}}, ptr align 4 %{{.+}}, i64 8, i1 false)
// CHECK-NOT: memcpy
// CHECK: ret ptr
struct WithNamedUnion {
U u;
int x;
};
// A named union member is copied as part of the containing class's defaulted
// assignment.
void assign_named(WithNamedUnion *d, const WithNamedUnion *s) { *d = *s; }
// CHECK-LABEL: define dso_local void @_Z12assign_namedP14WithNamedUnionPKS_(ptr noundef %{{.+}}, ptr noundef %{{.+}})
// CHECK: call void @llvm.memcpy.p0.p0.i64(ptr align 4 %{{.+}}, ptr align 4 %{{.+}}, i64 8, i1 false)
struct WithAnonUnion {
union {
int a;
float b;
};
int x;
};
// An anonymous union member is likewise copied.
void assign_anon(WithAnonUnion *d, const WithAnonUnion *s) { *d = *s; }
// CHECK-LABEL: define dso_local void @_Z11assign_anonP13WithAnonUnionPKS_(ptr noundef %{{.+}}, ptr noundef %{{.+}})
// CHECK: call void @llvm.memcpy.p0.p0.i64(ptr align 4 %{{.+}}, ptr align 4 %{{.+}}, i64 8, i1 false)

View file

@ -0,0 +1,28 @@
// RUN: %clang_cc1 -triple x86_64-unknown-linux-gnu -std=c++17 -fsyntax-only -verify %s
// expected-no-diagnostics
// The memcpy body must not block constant evaluation of a union assignment.
union U {
int a;
float b;
};
constexpr int copy_active() {
U x{};
x.a = 7;
U y{};
y = x;
return y.a;
}
constexpr int move_active() {
U x{};
x.a = 9;
U y{};
y = static_cast<U &&>(x);
return y.a;
}
static_assert(copy_active() == 7);
static_assert(move_active() == 9);

View file

@ -0,0 +1,33 @@
// RUN: %clang_cc1 -triple x86_64-unknown-linux-gnu -std=c++17 -fsyntax-only \
// RUN: -Wnontrivial-memcall -Wdeprecated-copy-with-user-provided-dtor -verify %s
struct NonTrivialDtor {
~NonTrivialDtor();
};
union U {
NonTrivialDtor n;
int i;
};
// Odr-use both defaulted assignment operators so their bodies are synthesized.
// The synthesized memcpy must not warn.
auto get_copy = static_cast<U &(U::*)(const U &)>(&U::operator=);
auto get_move = static_cast<U &(U::*)(U &&)>(&U::operator=);
// A user-written memcpy of the same union is not suppressed and still warns.
void user_memcpy(U *d, const U *s) {
__builtin_memcpy(d, s, sizeof(U)); // expected-warning {{first argument in call to '__builtin_memcpy' is a pointer to non-trivially copyable type 'U'}} expected-note {{explicitly cast the pointer to silence this warning}}
}
// The memcpy suppression is scoped to the synthesized call, so an unrelated
// warning for the same union still fires: a user-provided destructor deprecates
// the implicit copy assignment.
union V {
int i;
~V() {} // expected-warning {{definition of implicit copy assignment operator for 'V' is deprecated because it has a user-provided destructor}}
};
void use_deprecated_copy(V &a, const V &b) {
a = b; // expected-note {{in implicit copy assignment operator for 'V' first required here}}
}